Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8fc0461
build(reproducibility): add lockfile change-control CI gates
seonghobae Aug 10, 2026
5e4cc21
build(reproducibility): pin npm identity and install-script policy
seonghobae Aug 10, 2026
92bc24a
build(reproducibility): fail closed on unreviewed install scripts
seonghobae Aug 10, 2026
51a469f
docs(reproducibility): add package-manager evidence contract
seonghobae Aug 10, 2026
8b48575
feat(reproducibility): add lockfile change-control evaluator
seonghobae Aug 10, 2026
a22f7dc
test(reproducibility): cover lockfile change-control semantics
seonghobae Aug 10, 2026
e5b4f1f
test(reproducibility): cover bounded lockfile evidence boundaries
seonghobae Aug 10, 2026
681664b
test(reproducibility): preserve array order in evidence hashing
seonghobae Aug 10, 2026
a96ce3a
test(reproducibility): reject duplicate decoded JSON keys
seonghobae Aug 10, 2026
9c2bb4d
test(reproducibility): reject unenforced policy fields
seonghobae Aug 10, 2026
8bbb695
test(reproducibility): cover lockfile read race boundaries
seonghobae Aug 10, 2026
db9854a
test(reproducibility): enforce package-manager doctoring
seonghobae Aug 10, 2026
a41e72d
test(reproducibility): preserve nanoid predecessor contract
seonghobae Aug 10, 2026
450700d
test(reproducibility): prove npm identity and strict install policy
seonghobae Aug 10, 2026
16a5db0
test(reproducibility): accept exact Node 24 patch pins
seonghobae Aug 10, 2026
2fc6e8d
test(reproducibility): include lockfile control in coverage gate
seonghobae Aug 10, 2026
ebde372
docs(changelog): record deterministic npm toolchain
seonghobae Aug 10, 2026
da48a97
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 11, 2026
4f7077b
Merge protected main into deterministic npm toolchain
seonghobae Aug 12, 2026
b996b5b
test(supply-chain): define versioned lockfile approval evidence
seonghobae Aug 12, 2026
f6ca9ed
fix(supply-chain): enforce lockfile policy schema v3
seonghobae Aug 12, 2026
31932b3
docs(supply-chain): align lockfile schema v3 contract
seonghobae Aug 12, 2026
9d0465f
docs(changelog): record lockfile policy schema v3
seonghobae Aug 12, 2026
70dcc89
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 12, 2026
922cade
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 12, 2026
d04eb1f
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 12, 2026
ed0bc6e
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 12, 2026
b2efd96
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 12, 2026
3110231
Merge branch 'main' into build/deterministic-npm-toolchain-on-main
opencode-agent[bot] Aug 12, 2026
317f9e4
test(lockfile): align regression suite with schema v3
seonghobae Aug 12, 2026
5626b43
test(lockfile): align boundary coverage with schema v3
seonghobae Aug 12, 2026
a075de9
test(lockfile): enforce closed schema v3 policy shape
seonghobae Aug 12, 2026
c8332e4
test(lockfile): align race regression with schema v3
seonghobae Aug 12, 2026
673ffa3
test(docs): bind package-manager doctoring to schema v3
seonghobae Aug 12, 2026
05357f3
test(workflows): validate every Node version syntax
seonghobae Aug 12, 2026
22d9a69
test(lockfile): require diagnostic input failures on stderr
seonghobae Aug 12, 2026
6215f33
revert(test): defer stderr diagnostics without source patch support
seonghobae Aug 12, 2026
96e6f57
test(lockfile): cover digest input rejection
seonghobae Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false

- name: verify exact checkout
Expand Down Expand Up @@ -49,8 +50,110 @@ jobs:
test "$(node --version)" = "v24.19.0"
test "$(npm --version)" = "11.17.0"

- name: verify live pull-request base before lockfile control
if: github.event_name == 'pull_request'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
printf '::error::Invalid pull-request base SHA.\n'
exit 1
fi
if [ -z "$NOEMA_PR_BASE_REF" ]; then
printf '::error::Pull-request base ref is unavailable.\n'
exit 1
fi
repository_name="${GITHUB_REPOSITORY#*/}"
live_base_sha="$(
gh api graphql \
-f query='query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$qualifiedName){target{oid}}}}' \
-F owner="$GITHUB_REPOSITORY_OWNER" \
-F name="$repository_name" \
-F qualifiedName="refs/heads/${NOEMA_PR_BASE_REF}" \
--jq '.data.repository.ref.target.oid'
)"
if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\n'
exit 1
fi
if [ "$live_base_sha" != "$NOEMA_PR_BASE_SHA" ]; then
printf '::error::Pull-request base branch advanced from %s to %s.\n' \
"$NOEMA_PR_BASE_SHA" "$live_base_sha"
exit 1
fi
test "$live_base_sha" = "$NOEMA_PR_BASE_SHA"

- name: verify lockfile change control
if: github.event_name == 'pull_request'
shell: bash
env:
NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
printf '::error::Invalid pull-request base SHA.\n'
exit 1
fi
base_lock="$RUNNER_TEMP/noema-package-lock-base.json"
git show "${NOEMA_PR_BASE_SHA}:package-lock.json" >"$base_lock"
NOEMA_LOCKFILE_BASE_PATH="$base_lock" \
NOEMA_LOCKFILE_BASE_SHA="$NOEMA_PR_BASE_SHA" \
node --input-type=module <<'NODE'
import { runLockfileChangeControl } from "./scripts/lockfile-change-control.mjs";

const result = runLockfileChangeControl();
if (!result.passed) {
for (const failure of result.failures) {
console.error(`::error::${failure}`);
}
process.exit(1);
}
console.log(`Lockfile change control passed for ${result.changedPackages.length} changed package node(s).`);
NODE

- name: install
run: npm ci --legacy-peer-deps=false --install-links=false

- name: release verify
run: npm run release:verify

- name: refuse pull-request base drift after verification
if: github.event_name == 'pull_request'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
printf '::error::Invalid pull-request base SHA.\n'
exit 1
fi
if [ -z "$NOEMA_PR_BASE_REF" ]; then
printf '::error::Pull-request base ref is unavailable.\n'
exit 1
fi
repository_name="${GITHUB_REPOSITORY#*/}"
live_base_sha="$(
gh api graphql \
-f query='query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$qualifiedName){target{oid}}}}' \
-F owner="$GITHUB_REPOSITORY_OWNER" \
-F name="$repository_name" \
-F qualifiedName="refs/heads/${NOEMA_PR_BASE_REF}" \
--jq '.data.repository.ref.target.oid'
)"
if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\n'
exit 1
fi
if [ "$live_base_sha" != "$NOEMA_PR_BASE_SHA" ]; then
printf '::error::Pull-request base branch advanced during verification from %s to %s.\n' \
"$NOEMA_PR_BASE_SHA" "$live_base_sha"
exit 1
fi
test "$live_base_sha" = "$NOEMA_PR_BASE_SHA"
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
strict-allow-scripts=true
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased
- 개발 의존성 체인의 transitive `nanoid` lockfile resolution을 `3.3.16`에서 `3.3.17`로 최소 갱신하여 GHSA-2v37-7h3g-55p8 / CVE-2026-67213 보안 게이트를 복구한다. PostCSS의 선언 범위 `^3.3.16`과 다른 package metadata는 변경하지 않으며 audit waiver·ignore·severity 완화 없이 `npm ci`/`npm audit --audit-level=high`가 exact head에서 재검증되도록 유지한다.
- lockfile 재생성 도구 체인을 Node.js 24.19.0/npm 11.17.0으로 정확히 고정하고, `strict-allow-scripts=true` 아래 승인된 install-script identity만 실행하며 schema v2 exact-base lockfile change control로 package metadata drift를 실패-폐쇄한다. #76의 `nanoid@3.3.17` 보안 수정과 explicit `npm ci --legacy-peer-deps=false --install-links=false` 계약을 보존하고, package-manager/toolchain·install-script authority·vulnerability audit·review/merge authority를 별도 증거 계층으로 유지한다.
- `hourly-product-development`가 `NVIDIA_NIM_API_KEY`뿐 아니라 `NOEMA_MAINTAINER_APP_CLIENT_ID`와 `NOEMA_MAINTAINER_APP_PRIVATE_KEY` 존재를 checkout·OpenCode 설치·NVIDIA 호출 전에 검증한다. 게시 경로가 준비되지 않았으면 `maintainer_app_unavailable`로 실패 폐쇄하여 알려진 실패에 추론 비용을 쓰지 않으며, `dry_run`은 credential 없이 queue와 task contract를 검토하는 경로로 유지한다. 기존 reviewer App 및 `NOEMA_LLM_API_KEY`·`contextual-orchestrator` reviewer credential 경계는 변경하지 않는다.
- zero open pull requests일 때만 `NVIDIA_NIM_API_KEY` 전용 OpenCode 1.17.13 세션을 실행하는 proposal-only `hourly-product-development` 루프를 추가. minute-47 schedule·non-cancelling single flight·OpenCode binary SHA-256 pin·NVIDIA NIM model fallback·후보 실패 시 clean reset·GitHub/OIDC credential 제거·reviewer key 비참조·full release verification·40-file/500,000-byte proposal budget·trusted one-PR packaging을 강제한다. 각 후보 실행은 900초와 30초 kill grace로 제한하고, 실패 후 `npm ci --ignore-scripts` 재설치는 별도 60초와 10초 kill grace로 제한한다. 재설치가 실패하거나 시간 초과되면 불완전한 dependency tree로 다음 후보를 실행하지 않고 실패 폐쇄한다. 세 후보의 실행·종료 2,790초, 두 번의 후보 간 재설치 140초, 300초 setup/diagnostic reserve를 합친 3,230초가 55분(3,300초) job budget에 들어가며 70초 여유를 남긴다. 마지막 후보가 실패하면 불필요한 reset·clean·재설치를 생략하고 안정적인 전체 후보 실패 진단으로 곧바로 종료한다. 모델 실행, 제안 코드 검증, publication credential을 각각 별도의 GitHub-hosted runner로 분리하고, immutable artifact의 exact ID·workflow-run ID·archive digest와 patch SHA-256·base SHA·file/byte count를 교차 검증하며 symlink(`120000`)와 gitlink(`160000`)를 세 경계 모두에서 차단한다. 제안 코드를 실행한 runner에는 Maintainer App secret/token을 절대 제공하지 않고, 세 번째 non-executing publisher에서만 late-bound repository-scoped App token을 발급한다. merge/release/deploy authority는 기존 `hourly-commercial-readiness` exact-head governance에 유지하며, 운영 Runbook과 OpenCode/NVIDIA/GitHub Actions/NIST SP 800-218 근거를 APA 7th doctoring에 기록했다. package version은 release·deployment·production KPI evidence를 발행하지 않으므로 유지한다.
- `/health` liveness와 분리된 unauthenticated `GET`/`HEAD /ready` runtime readiness endpoint를 추가. GitHub Actions OIDC issuer·audience·organization/workflow binding·exact workflow ref·GitHub Cloud API origin·GitHub App identifiers·PKCS#8 private key를 외부 호출 없이 검증하며, 불완전한 설정은 secret/config value를 반사하지 않는 deterministic failure codes와 `503 ERR_SERVICE_NOT_READY`, `Retry-After`, no-store/nosniff/trace/latency headers로 실패-폐쇄한다. exact workflow named ref는 Git `check-ref-format`의 모호성·유효성 경계(`..`, `//`, dot-leading/`.lock` component, revision-expression 문자, trailing dot/slash 등)를 만족해야 하므로 GitHub가 실제로 표현할 수 없는 ref에서 false-ready가 발생하지 않는다. 배포 smoke contract가 liveness·runtime readiness·unauthenticated exchange challenge를 모두 요구하도록 확장하고 Kubernetes probe separation, RFC 9110, NIST SSDF, Git ref-format 근거를 APA 7th doctoring에 기록했다.
Expand Down
87 changes: 87 additions & 0 deletions docs/doctoring/package-manager-reproducibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 패키지 관리자·lockfile 재현성 계약

## 결정

Noema의 검토·CI·lockfile 재생성 기준 개발 도구 체인은 **Node.js 24.19.0**과 **npm 11.17.0**으로 고정한다. `package.json`의 `packageManager`와 `devEngines`가 그 identity를 선언하고, CI는 설치 전에 실제 `node --version`과 `npm --version`을 exact-match로 다시 확인한다. 제품 자체의 지원 범위인 `engines.node >=22`와 개발·lockfile 생성 도구 체인은 별도 계약이다.

CI의 JavaScript Action도 mutable major tag를 실행 근거로 사용하지 않는다. `actions/checkout` 6.0.2와 `actions/setup-node` 6.4.0의 검토된 full commit SHA를 workflow에 고정한다. 두 action 계열은 Node 24 runtime을 사용하는 현재 release line이다.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Install-script 실행 권한

프로젝트 `.npmrc`는 `strict-allow-scripts=true`를 요구한다. 현재 `package.json`에서 실행을 허용한 exact identity는 `esbuild@0.28.1`과 `workerd@1.20260625.1`이며, `fsevents@2.3.3`은 명시적 `false`다. `false`는 검토된 deny 결정이지 실행 권한이 아니다. 새 dependency lifecycle script가 policy에 없으면 `npm ci`가 성공해서는 안 된다.

이 경계는 vulnerability metadata와 별개다. `npm audit`이 clean이어도 install-time code가 검토됐다는 뜻이 아니며, 반대로 install-script policy는 알려진 vulnerability를 면제하지 않는다. `--dangerously-allow-all-scripts`, blanket script 허용, audit waiver, severity 완화는 이 계약의 remedy가 아니다.

실제 회귀 테스트는 local file dependency에 `postinstall` marker를 넣고, network 없이 lock을 만든 다음 strict policy 아래 `npm ci`가 non-zero로 끝나며 marker가 생성되지 않는지 확인한다. 따라서 단순 configuration-string 검사가 아니라 실행 경계 자체를 검증한다.

## Frozen install과 predecessor 보존

이 stack은 선행 보안 remediation이 고정한 `nanoid@3.3.17`을 그대로 보존한다. `npm ci --legacy-peer-deps=false --install-links=false`는 reviewed predecessor의 frozen-install 경계이며, 후속 재현성 작업이 이를 임의의 기본값으로 되돌리지 않는다.

npm 문서가 설명하듯 `npm ci`는 project manifest와 lockfile이 불일치하면 lockfile을 고쳐 주는 복구 경로가 아니라 실패하는 clean-install 경로다. 따라서 CI가 lockfile을 생성·수정하거나 PR branch를 자동 수선하지 않는다.

## Live base와 lockfile change control

Pull request event의 base snapshot을 현재 base branch tip으로 오인하지 않는다. CI는 lockfile change control 전에 live base ref를 독립 조회하여 event의 exact base SHA와 같음을 요구하고, release verification 뒤에도 같은 조회를 반복해 검증 도중 base가 이동하지 않았음을 확인한다.

변경이 없는 lockfile은 별도 승인 객체를 만들지 않는다. lockfile이 바뀌면 `.github/lockfile-change-policy.json`의 **schemaVersion 2** closed contract가 필요하다. 이 contract는 다음을 모두 결합한다.

- exact base SHA;
- 변경된 `packages` key의 정확한 전체 집합;
- 각 key의 exact **before/after** package object에 대한 canonical SHA-256;
- bounded justification;
- bounded HTTPS primary-source evidence.

`schemaVersion 2`에는 `schemaVersion`, `baseSha`, `targetPackages`, `packageDigests`, `justification`, `sources`만 허용한다. validator가 집행하지 않는 `approvedBy`, `waiver`, `expiresAt` 같은 그럴듯한 필드를 추가해 사람에게 허위 보증을 주는 것을 금지한다. 새로운 의미가 필요하면 schema version·validator·tests·doctoring을 함께 변경한다.

Canonical hashing은 object key 순서를 정규화하지만 array 순서는 보존한다. package 생성/삭제는 값이 없는 상태와 JSON `null`을 구분한다. 같은 package path 안에서도 `version`, `resolved`, `integrity`, `license`, `bin`, dependency metadata가 달라지면 digest가 달라져 기존 policy는 실패한다.

## JSON·filesystem 신뢰 경계

`package-lock.json`, base snapshot, change policy는 malformed·oversized·invalid UTF-8 입력을 거부한다. JSON object key는 escape decoding 뒤에도 유일해야 한다. 예를 들어 `baseSha`와 `base\u0053ha`가 같은 object에 동시에 존재하는 **duplicate** key ambiguity는 parser의 last-value 선택에 맡기지 않고 실패 폐쇄한다.

File evidence는 descriptor 기반 bounded read를 사용한다. regular file이 아니거나 symlink를 통하거나 byte ceiling을 넘거나, 읽는 동안 inode/device/mode/size/mtime/ctime identity가 바뀌면 거부한다. runtime에 no-follow semantics가 없다면 안전하다고 추정하지 않는다.

## 증거 권한 분리

Lockfile policy와 CI 결과는 supply-chain evidence일 뿐 **not merge authority**다. 다음은 서로 독립적인 evidence/authority class로 유지한다.

- exact source head와 independently resolved live base;
- package-manager/toolchain identity;
- install-script authorization;
- lockfile change authorization evidence;
- vulnerability audit/scanner evidence;
- check runs와 commit statuses;
- formal review와 model judgement;
- merge/protected-main acceptance;
- release·deployment·acquisition evidence.

Predecessor head의 green run, model comment, status-only signal, stale base, synthetic merge scan 또는 queued/pending check를 새 exact head의 성공으로 이전하지 않는다.

## 재생성·검증 절차

1. Node.js 24.19.0 / npm 11.17.0 exact identity를 확인한다.
2. 현재 protected/base lockfile을 immutable 비교 기준으로 보존한다.
3. 목표 dependency만 변경하고 unrelated metadata churn을 별도 근거 없이 수용하지 않는다.
4. lockfile diff가 존재하면 schemaVersion 2 policy를 exact base와 exact before/after object digest에 결합한다.
5. `npm ci --legacy-peer-deps=false --install-links=false`로 frozen install을 검증한다.
6. exact head에서 typecheck, complete tests, 100% configured production coverage, `npm audit --audit-level=high`, repository CI와 applicable reviewer/security/provenance gates를 다시 실행한다.
7. base가 verification 중 이동했으면 결과를 폐기하고 새 live base에서 다시 판단한다.

CI나 GitHub Actions가 lockfile이나 PR branch를 자체 수정하는 `.github/workflows/repair-*`, self-modifying workflow 또는 `contents:write` repair path는 사용하지 않는다.

## 참고문헌 — APA 7

GitHub. (2026). *actions/checkout releases*. GitHub. https://github.com/actions/checkout/releases

GitHub. (2026). *actions/setup-node releases*. GitHub. https://github.com/actions/setup-node/releases

npm, Inc. (2026). *npm ci*. npm Docs. https://docs.npmjs.com/cli/v11/commands/npm-ci/

npm, Inc. (2026). *package.json*. npm Docs. https://docs.npmjs.com/files/package.json

npm, Inc. (2026). *.npmrc*. npm Docs. https://docs.npmjs.com/cli/v11/configuring-npm/npmrc/

npm, Inc. (2026). *npm approve-scripts*. npm Docs. https://docs.npmjs.com/cli/v11/commands/npm-approve-scripts/

npm, Inc. (2026). *npm CLI v11 changelog*. npm Docs. https://docs.npmjs.com/cli/v11/using-npm/changelog/
18 changes: 18 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,27 @@
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "npm@11.17.0",
"engines": {
"node": ">=22"
},
"devEngines": {
"runtime": {
"name": "node",
"version": "24.19.0",
"onFail": "error"
},
"packageManager": {
"name": "npm",
"version": "11.17.0",
"onFail": "error"
}
},
"allowScripts": {
"esbuild@0.28.1": true,
"fsevents@2.3.3": false,
"workerd@1.20260625.1": true
},
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
Expand Down
Loading
Loading