Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
790e397
fix(coverage): trust validated head-mutated pnpm locks via manifest r…
seonghobae Aug 25, 2026
956b11e
test(coverage): require lowercase pnpm manifest identities
seonghobae Aug 25, 2026
c5b3856
test(coverage): reject comma-greedy pnpm resolution parsing
seonghobae Aug 25, 2026
a56c4d1
fix(coverage): bound pnpm inline resolution tokens
seonghobae Aug 25, 2026
11bc884
fix(coverage): normalize pnpm manifest identities
seonghobae Aug 25, 2026
f381885
test(coverage): pin normalized dispatch workflow
seonghobae Aug 25, 2026
ac714df
fix(coverage): correct pnpm regex escapes
seonghobae Aug 25, 2026
7a90dea
test(strix): align direct fallback queue contract
seonghobae Aug 25, 2026
fde65d9
test(strix): align NVIDIA fallback contract
seonghobae Aug 25, 2026
9f31a17
fix(review): align direct OpenAI fallback model
seonghobae Aug 25, 2026
6dbdb1f
test(review): align direct OpenAI fallback assertions
seonghobae Aug 25, 2026
004216f
test(review): pin aligned dispatch workflow
seonghobae Aug 25, 2026
0b41da2
docs(coverage): correct pnpm trust references
seonghobae Aug 25, 2026
b12814d
docs(coverage): preserve authoritative OWASP URL
seonghobae Aug 25, 2026
610c740
test(coverage): reproduce pnpm metadata source-word false rejection
seonghobae Aug 25, 2026
ab37151
fix(coverage): classify only pnpm fetch declarations
seonghobae Aug 25, 2026
8f375fe
fix(review): bind pnpm manifest manager and current model docs
seonghobae Aug 25, 2026
86f8d7c
test(review): pin pnpm manager-bound dispatch workflow
seonghobae Aug 25, 2026
70ae3e6
test(review): require pnpm manifest manager binding
seonghobae Aug 25, 2026
1d817cd
fix(coverage): restore executable pnpm source guard
seonghobae Aug 25, 2026
889ebec
test(coverage): reproduce versioned pnpm manifest mismatch
seonghobae Aug 25, 2026
ca9b5b9
fix(review): match versioned pnpm manifest specs
seonghobae Aug 25, 2026
f0d907a
test(review): pin version-aware pnpm manifest workflow
seonghobae Aug 25, 2026
59315de
test(review): require version-aware pnpm manager predicate
seonghobae Aug 25, 2026
c5d847e
merge: reconcile protected main into pnpm lock trust repair
seonghobae Aug 25, 2026
6bc96d4
test(coverage): exercise pnpm lock fail-closed branches
seonghobae Aug 25, 2026
3058b18
Merge protected main into pnpm lock trust owner
seonghobae Aug 25, 2026
d798c4c
fix(ci): restore complete merged OpenCode workflow
seonghobae Aug 25, 2026
e1f1475
Merge origin/main into fix/pnpm-head-lock-validation
seonghobae Aug 25, 2026
c694825
fix(ci): escape materializer diagnostics
seonghobae Aug 25, 2026
20c744f
Merge branch 'main' into fix/pnpm-head-lock-validation
opencode-agent[bot] Aug 26, 2026
335e365
docs(copy): make dependency update guidance actionable
seonghobae Aug 26, 2026
275d4d6
Merge branch 'main' into fix/pnpm-head-lock-validation
opencode-agent[bot] Aug 26, 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
37 changes: 31 additions & 6 deletions .github/workflows/opencode-review-dispatch.yml
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,18 @@ jobs:
writable_npm_cache_dir="$destination"
}

trusted_manifest_records_lock_revision() {
local relative_lock="$1"
local head_blob="$2"
jq -e \
--arg source "$relative_lock" \
--arg manager "pnpm" \
--arg revision "${PR_HEAD_SHA,,}" \
--arg blob "${head_blob,,}" \
'any(.[]; .source == $source and (.package_manager | startswith($manager + "@")) and .revision_sha == $revision and .lock_blob == $blob)' \
/opt/javascript-package-locks/manifest.json >/dev/null 2>&1
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
trusted_pnpm_lock_matches_base() {
local relative_dir
local relative_lock
Expand All @@ -1455,10 +1467,6 @@ jobs:
return 1
fi

base_blob="$(trusted_git rev-parse "${PR_BASE_SHA}:${relative_lock}" 2>/dev/null)" || {
echo "::error::Validated base does not contain ${relative_lock}; refusing to trust a PR-added lockfile."
return 1
}
head_blob="$(trusted_git rev-parse "${PR_HEAD_SHA}:${relative_lock}" 2>/dev/null)" || {
echo "::error::Validated head does not contain ${relative_lock}."
return 1
Expand All @@ -1470,10 +1478,27 @@ jobs:
echo "::error::Could not hash current pnpm lock ${relative_lock}."
return 1
}
if [ "$base_blob" != "$head_blob" ] || [ "$head_blob" != "$worktree_blob" ]; then
echo "::error::Current pnpm lock ${relative_lock} differs from the validated base; refusing --trust-lockfile for PR-controlled dependency resolution."
if [ "$head_blob" != "$worktree_blob" ]; then
echo "::error::Current pnpm lock ${relative_lock} does not match the validated HEAD; refusing --trust-lockfile because the coverage source artifact was tampered with."
return 1
fi

base_blob="$(trusted_git rev-parse "${PR_BASE_SHA}:${relative_lock}" 2>/dev/null)" || base_blob=""
if [ -n "$base_blob" ] && [ "$base_blob" = "$head_blob" ]; then
return 0
fi

# A PR-mutated lock is trusted only when the trusted materializer
# recorded this exact lock blob from the validated HEAD revision.
# That record proves the offline store was prefetched from the same
# hash-bounded lock (registry- and integrity-validated at image
# build), so a frozen offline install cannot resolve anything else.
if trusted_manifest_records_lock_revision "$relative_lock" "$head_blob"; then
return 0
fi
Comment thread
seonghobae marked this conversation as resolved.

echo "::error::Current pnpm lock ${relative_lock} differs from the validated base and was not materialized from the validated HEAD; refusing --trust-lockfile for PR-controlled dependency resolution."
return 1
}
Comment thread
seonghobae marked this conversation as resolved.

prepare_writable_pnpm_store() {
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ this file. The format follows Keep a Changelog, and versioned releases follow
Semantic Versioning where the repository publishes a release.

## [Unreleased]
- Dependency updates now keep coverage evidence when the lock file passes
validation. If validation reports a problem, refresh the lock file and run
the review again before merging.
- Route Strix cross-provider fallbacks to explicit direct-OpenAI models
(`openai-direct/...`) through the OpenAI inference endpoint instead of
inheriting a provider-specific primary base: the workflow now provisions
Expand Down
78 changes: 78 additions & 0 deletions docs/doctoring/opencode-pnpm-head-lock-trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# OpenCode coverage validated head pnpm locks

검토 기준일: **2026-08-25**

## Decision

OpenCode coverage-evidence now trusts a PR-mutated `pnpm-lock.yaml` only when
the trusted materializer recorded that exact lock blob from the validated HEAD
revision. The sandbox keeps three integrity boundaries and adds a fourth:

1. The coverage source artifact must hash-match the validated `PR_HEAD_SHA`
lock (tamper evidence between artifact download and use).
2. An unchanged base lock (base blob equals head blob) remains trusted exactly
as before.
3. A head-mutated lock is trusted only when
`/opt/javascript-package-locks/manifest.json` records
`source`, `revision_sha == PR_HEAD_SHA`, and `lock_blob` for this project —
proving the offline store was prefetched from the same hash-bounded lock at
image build time.
4. Before materialization, `validate_head_pnpm_lock` fails closed unless every
package entry pins one SHA-512 SRI, every tarball URL is an HTTPS
`registry.npmjs.org` URL without userinfo, port, query, or fragment, and any
workspace link target is a relative in-project directory. VCS or file
sources are refused.

The npm path already followed this pattern through
`validate_head_npm_lock`; the pnpm path now mirrors it. `--offline`,
`--frozen-lockfile`, and lifecycle-hook suppression remain mandatory, so a
mutated lock can never fetch anything outside the store that was verified
against the registry's own integrity metadata during image build (npm, n.d.;
pnpm, n.d.).

## Root-cause analysis

1. The previous gate required base blob == head blob == worktree blob for
every pnpm project. Any dependency-raising pull request necessarily mutates
the lockfile, so such PRs failed coverage-evidence with "Current pnpm lock
differs from the validated base" regardless of content quality.
2. The failure was not hypothetical: ContextualWisdomLab/inkspan#373 (a
transitive security-floor raise for fast-uri, nanoid, and postcss) carried
fully green repository-owned checks but could never satisfy this gate,
leaving the security fix unmergeable while Dependabot alerts stayed open.
3. The image build already consumed strictly registry/hash-bounded inputs from
the live-validated HEAD (`materialize_base_javascript_packages.py --head-sha`),
so refusing head-mutated pnpm locks added no integrity guarantee that the
build did not already enforce; it only blocked legitimate dependency work.

## Remediation

- Materializers validate changed head pnpm locks with the same fail-closed
posture as npm locks before anything enters the networked build context.
- The sandbox consults the trusted manifest record instead of refusing every
mutation, keeping tamper evidence against `PR_HEAD_SHA`.
- Repositories regain the ability to ship audited dependency updates through
reviewed pull requests instead of forcing direct-to-main writes.

Independent OpenCode, Strix, and Noema review remain authorization gates. This
change does not approve, merge, or weaken hash-pinned Python installs, registry
allowlists, or the networkless PR sandbox.

## APA 7th references

MITRE. (2026). *CWE-494: Download of code without integrity check*.
https://cwe.mitre.org/data/definitions/494.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

npm, Inc. (n.d.). *Package lock specification: integrity fields*. npm Docs.
Retrieved August 25, 2026, from https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json

Open Worldwide Application Security Project. (2025). *OWASP Top 10: A06
— vulnerable and outdated components*. https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/

pnpm. (n.d.). *Settings: lockfile and frozen-lockfile*. pnpm Docs.
Retrieved August 25, 2026, from https://pnpm.io/settings
196 changes: 192 additions & 4 deletions scripts/ci/materialize_base_javascript_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,33 @@
PNPM_SPEC_RE = re.compile(r"^pnpm@[0-9]+\.[0-9]+\.[0-9]+(?:[+-][A-Za-z0-9._+-]+)?$")
PNPM_BASE_INPUT_NAMES = ("package.json", "pnpm-workspace.yaml", ".pnpmfile.cjs")
NPM_LOCK_NAMES = ("npm-shrinkwrap.json", "package-lock.json")
PNPM_LOCK_NAME = "pnpm-lock.yaml"
NPM_REGISTRY_HOST = "registry.npmjs.org"
SHA512_SRI_RE = re.compile(r"^sha512-[A-Za-z0-9+/]{86}==$")
PNPM_PACKAGE_ENTRY_RE = re.compile(r"^ ([^ #][^:]*):(?:\s.*)?$")
PNPM_RESOLUTION_RE = re.compile(r"resolution:\s*\{(.*)\}\s*$")
PNPM_TARBALL_RE = re.compile(r"tarball:\s*([^,\s}]+)")
PNPM_INTEGRITY_RE = re.compile(r"integrity:\s*([^,\s}]+)")
PNPM_DIRECTORY_RE = re.compile(r"directory:\s*\"?([^,\"}]+)\"?")
PNPM_LINK_TRUE_RE = re.compile(r"link:\s*true\b")


def _github_actions_escape(value: object) -> str:
"""Escape untrusted text before writing it to a GitHub Actions log.

GitHub Actions recognizes workflow commands in log lines. Repository paths
and git diagnostics can contain command delimiters, newlines, or percent
escapes when a pull request controls the tree, so diagnostics must never be
emitted verbatim. The manifest itself remains raw; this helper only protects
the human-readable CLI output.
"""
return (
str(value)
.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
.replace(":", "%3A")
)


def _git(repo_root: pathlib.Path, *args: str) -> bytes:
Expand Down Expand Up @@ -340,6 +365,147 @@ def validate_head_npm_lock(lock_path: str, lock_content: bytes) -> None:
)


def _validate_pnpm_tarball_url(
lock_path: str, package_key: str, tarball_url: str
) -> None:
"""Fail closed unless one pnpm tarball URL is an npm-registry HTTPS URL."""
parsed = urllib.parse.urlsplit(tarball_url)
try:
parsed_port = parsed.port
except ValueError as exc:
raise ValueError(
f"current-head pnpm lock {lock_path} package {package_key} has an invalid tarball URL"
) from exc
if (
parsed.scheme != "https"
or parsed.hostname != NPM_REGISTRY_HOST
or parsed.username is not None
or parsed.password is not None
or parsed_port is not None
or parsed.query
or parsed.fragment
or not parsed.path.startswith("/")
or not parsed.path.endswith(".tgz")
):
raise ValueError(
f"current-head pnpm lock {lock_path} package {package_key} must resolve from https://{NPM_REGISTRY_HOST}/"
)


def validate_head_pnpm_lock(lock_path: str, lock_content: bytes) -> None:
"""Fail closed unless a changed HEAD pnpm lock is registry- and hash-bounded.

The validator is intentionally line-based and standard-library-only: pnpm
lockfiles always emit each package's ``resolution`` as a single-line inline
mapping, so scanning those lines covers every fetched artifact while never
introducing a YAML parser dependency into the trusted materializer.
"""
try:
text = lock_content.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(
f"current-head pnpm lock {lock_path} is invalid UTF-8: {exc}"
) from exc
if not text.strip():
raise ValueError(f"current-head pnpm lock {lock_path} is empty")

in_packages_section = False
package_entry_count = 0
current_package_key = ""
current_resolution_seen = False

for raw_line in text.splitlines():
line = raw_line.rstrip()
if not line or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
stripped = line.strip()

if indent == 0 and stripped.endswith(":"):
in_packages_section = stripped == "packages:"
continue
if not in_packages_section:
continue

if indent == 2:
entry_match = PNPM_PACKAGE_ENTRY_RE.match(line)
if entry_match is None:
raise ValueError(
f"current-head pnpm lock {lock_path} contains an unexpected "
f"two-space entry {stripped!r}"
)
if current_package_key and not current_resolution_seen:
raise ValueError(
f"current-head pnpm lock {lock_path} package {current_package_key} "
"has no resolution entry"
)
current_package_key = entry_match.group(1).strip()
package_entry_count += 1
current_resolution_seen = False
continue
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.

if current_package_key and stripped.startswith("resolution:"):
resolution_match = PNPM_RESOLUTION_RE.search(stripped)
if resolution_match is None:
raise ValueError(
f"current-head pnpm lock {lock_path} package {current_package_key} "
"has a multi-line or malformed resolution mapping"
)
resolution_body = resolution_match.group(1)
integrity_match = PNPM_INTEGRITY_RE.search(resolution_body)
link_match = PNPM_LINK_TRUE_RE.search(resolution_body)
directory_match = PNPM_DIRECTORY_RE.search(resolution_body)
if link_match is not None:
if directory_match is None:
raise ValueError(
f"current-head pnpm lock {lock_path} workspace link "
f"{current_package_key} must carry a relative directory target"
)
directory_value = directory_match.group(1).strip().strip('"')
directory_candidate = pathlib.PurePosixPath(directory_value)
if (
directory_candidate.is_absolute()
or ".." in directory_candidate.parts
or "node_modules" in directory_candidate.parts
):
raise ValueError(
f"current-head pnpm lock {lock_path} workspace link "
f"{current_package_key} has an unsafe directory target"
)
elif integrity_match is None or not SHA512_SRI_RE.fullmatch(
integrity_match.group(1)
):
raise ValueError(
f"current-head pnpm lock {lock_path} package {current_package_key} "
"must pin exactly one SHA-512 integrity value"
)
tarball_match = PNPM_TARBALL_RE.search(resolution_body)
if tarball_match is not None:
_validate_pnpm_tarball_url(
lock_path, current_package_key, tarball_match.group(1)
)
Comment thread
seonghobae marked this conversation as resolved.
current_resolution_seen = True
continue

if current_package_key and (
stripped.startswith("tarball:") or stripped.startswith("git+")
):
raise ValueError(
f"current-head pnpm lock {lock_path} package {current_package_key} "
"carries an out-of-band fetch source"
)

if current_package_key and not current_resolution_seen:
raise ValueError(
f"current-head pnpm lock {lock_path} package {current_package_key} "
"has no resolution entry"
)
if package_entry_count == 0:
raise ValueError(
f"current-head pnpm lock {lock_path} contains no package entries"
)
Comment thread
seonghobae marked this conversation as resolved.


def materialize(
repo_root: pathlib.Path,
base_sha: str,
Expand All @@ -356,6 +522,7 @@ def materialize(
base_npm = base_npm_projects(repo_root, base_sha)
base_npm_paths = {source_path for source_path, _manager, _inputs in base_npm}
base_npm_blobs: dict[str, str] = {}
base_pnpm_blobs: dict[str, str] = {}
for source_path, package_manager, base_inputs in (
base_pnpm_projects(repo_root, base_sha) + base_npm
):
Expand All @@ -371,6 +538,8 @@ def materialize(
)
if source_path in base_npm_paths:
base_npm_blobs[source_path] = lock_blob
else:
base_pnpm_blobs[source_path] = lock_blob

if head_sha is not None:
if not SHA_RE.fullmatch(head_sha):
Expand All @@ -392,6 +561,22 @@ def materialize(
head_blob,
)
)
for source_path, package_manager, head_inputs in base_pnpm_projects(
repo_root, head_sha
):
head_blob = _lock_blob_sha(repo_root, head_sha, source_path)
if base_pnpm_blobs.get(source_path) == head_blob:
continue
validate_head_pnpm_lock(source_path, head_inputs[PNPM_LOCK_NAME])
projects.append(
(
source_path,
package_manager,
head_inputs,
head_sha.lower(),
head_blob,
)
)

for index, (
source_path,
Expand Down Expand Up @@ -442,7 +627,8 @@ def main(argv: list[str] | None = None) -> int:
)
except (OSError, RuntimeError, ValueError) as exc:
print(
f"::error::Could not materialize base JavaScript package locks: {exc}",
"::error::Could not materialize base JavaScript package locks: "
f"{_github_actions_escape(exc)}",
file=sys.stderr,
)
return 1
Expand All @@ -451,9 +637,11 @@ def main(argv: list[str] | None = None) -> int:
for entry in manifest:
print(
"Materialized trusted JavaScript lock "
f"{entry['source']} for {entry['package_manager']} "
f"from {entry['revision_sha']} as "
f"{entry['directory']}/{pathlib.PurePosixPath(entry['source']).name}."
f"{_github_actions_escape(entry['source'])} for "
f"{_github_actions_escape(entry['package_manager'])} from "
f"{_github_actions_escape(entry['revision_sha'])} as "
f"{_github_actions_escape(entry['directory'])}/"
f"{_github_actions_escape(pathlib.PurePosixPath(entry['source']).name)}."
)
else:
print(
Expand Down
Loading
Loading