feat(npm): add @mininglamp-oss/octo-cli npm wrapper#27
Conversation
Distribute octo-cli via npm for Node-based agent runtimes. The package is
a thin wrapper: postinstall downloads the prebuilt Go binary matching the
package version and host platform from the GitHub Release, verifies its
sha256, and the `octo-cli` bin shim execs it.
- npm/ — package.json (bin: octo-cli), install.js (download + checksum +
extract, with a friendly PATH hint on global installs when npm's bin dir
is not on PATH), run.js shim, README
- .github/workflows/npm-publish.yml — publishes on GitHub Release; aligned
with octo-adapters' publish flow: SHA-pinned actions, `permissions: {}`,
concurrency guard, --access public, NODE_AUTH_TOKEN, a prerelease→@next /
stable→@latest dist-tag rule, and a workflow_dispatch dry-run path
- .gitignore — ignore npm/bin/ (the downloaded binary)
- README — npm install section
The published npm version tracks a real release tag; install.js downloads
octo-cli_<version>_<os>_<arch> from that release, so publishing requires a
release whose goreleaser archives use the octo-cli_ prefix (v0.6.0+).
Dependency Changes DetectedThis PR modifies dependency files. Please review whether these changes are intentional. Changed files:
Maintainer checklist:
|
lml2468
left a comment
There was a problem hiding this comment.
Review: feat(npm): add @mininglamp-oss/octo-cli npm wrapper
Verdict: APPROVED
Blocking
None.
Non-blocking
- Race condition with goreleaser: if a GitHub Release is published before goreleaser finishes uploading assets, the npm package version would be live but
postinstallwould fail for early installers. Therelease: publishedtrigger likely fires after assets are uploaded, but worth keeping in mind. - No JS-level tests: the wrapper is thin enough (~195 lines total) that this is acceptable, but a small smoke test for
maybeHintPathor the platform mapping could catch regressions.
Highlights
- Checksum verification: SHA-256 against
checksums.txtbefore extraction — correct security posture. - Clean platform mapping:
process.platform → goreleaser osandprocess.arch → goreleaser archtables align exactly with.goreleaser.yaml'sname_templateandformat_overrides. - Graceful error paths:
0.0.0placeholder guard ininstall.js,ENOENThandling with reinstall hint inrun.js, redirect limit indownload(), and themaybeHintPath()best-effort PATH warning — all solid. - Pinned CI actions: checkout and setup-node pinned by SHA, not tag — good supply-chain hygiene.
- Concurrency guard:
cancel-in-progress: falseprevents double-publish races. - Dist-tag logic: prerelease versions (
-rc.1,-beta.2) correctly route to@nextinstead of@latest. - Minimal publish surface:
filesfield limits npm tarball toscripts/andREADME.md.
REVIEW_STATE=APPROVED
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-cli, but the npm publish trigger can publish an installable-looking package before the matching release binaries exist.
🔴 Blocking
- 🔴 Critical:
.github/workflows/npm-publish.yml:14-16publishes on the GitHub Releasepublishedevent, but the existing release flow uploads GoReleaser artifacts only after the release is published in.github/workflows/release-publish.yml:33-70. That meansnpm publishcan race ahead of artifact upload, and if the artifact build/upload fails, npm will permanently contain a version whosepostinstallcannot downloadocto-cli_<version>_<os>_<arch>orchecksums.txt. Please trigger npm publishing only after artifact upload succeeds, for example by adding it as a dependent job afterbuild-artifacts, triggering from a completed release workflow, or otherwise explicitly verifying the required assets exist beforenpm publish.
💬 Non-blocking
-
🟡 Warning:
npm/scripts/install.js:65-71has an incorrect fallback prefix calculation. For a Unix global install path like<prefix>/lib/node_modules/@scope/pkg/scripts, the fallback walks past<prefix>, so the PATH hint can point at the wrongbindirectory whennpm_config_prefixis unavailable. This is best-effort only, but worth correcting if keeping the fallback. -
🔵 Suggestion:
npm/scripts/install.js:29-52buffers the full download without a timeout or size guard. GitHub release assets are expected to be small and trusted here, but a request timeout would make failed installs less likely to hang indefinitely.
✅ Highlights
- Checksum verification is present before extraction in
npm/scripts/install.js:132-139. - The wrapper keeps the npm package thin and platform-gated via
os/cpuinnpm/package.json. run.jscorrectly forwards stdio and propagates the binary exit code.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Thanks for this — the wrapper is well thought through. SHA-pinned actions, permissions: {}, the 0.0.0 placeholder guard, the prerelease→@next dist-tag rule, and the checksum-before-extract flow are all done right, and the npm asset naming matches .goreleaser.yaml exactly (octo-cli_<version>_<os>_<arch>.{tar.gz|zip}, checksums.txt, os/arch maps, archive root layout — all verified, no mismatch). The publish flow will produce an installable package once a v0.6.0+ release exists.
Because this is a postinstall script that downloads and execs a native binary on every npm install, I held the network/trust paths to a high bar. One issue I'd ask to be fixed before merge, plus a cluster of security hardening on the download path that is worth doing while we're here.
Requested change (blocker)
1. download() has no request/socket timeout — a stalled connection hangs npm install forever — npm/scripts/install.js
https
.get(url, { headers: { "User-Agent": "octo-cli-npm-installer" } }, (res) => { ... })
.on("error", reject);There is no timeout option, no req.setTimeout, and no response idle timeout. .on("error") fires on socket errors (ECONNRESET/DNS), but not on silence: if the TCP connection is established and the CDN then stalls (half-open connection, dropped CDN node, captive portal that accepts the socket but never sends bytes), the promise never settles and the entire npm install freezes with the last line being [octo-cli] downloading ... . npm imposes no wall-clock limit on lifecycle scripts, so nothing rescues it; in CI it manifests as an opaque job-timeout. This is cheap to fix and worth doing for a script that runs on every install:
const req = https.get(url, { headers, timeout: 30000 }, (res) => { ... });
req.on("timeout", () => req.destroy(new Error(`request timed out: ${url}`)));Security hardening on the download path (strongly recommended)
This PR was flagged security-sensitive, and these tighten the install-time ACE surface. None is exploitable against stock GitHub today; they are defense-in-depth on a path that runs unattended.
2. Redirects are followed to any host with no allowlist — npm/scripts/install.js
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
resolve(download(res.headers.location, redirects + 1));
return;
}The redirect target is followed blindly with no host/protocol validation. The first hop is hardcoded to github.com, and an http:// target would throw (good — no silent transport downgrade), but a cross-host HTTPS redirect (GitHub open-redirect, DNS/CDN hijack of the redirect target, or a compromised release) would be fetched from the attacker host. The only backstop is the checksum, which is co-fetched from the same hop (see #4). Validate every hop:
const u = new URL(location, currentUrl);
if (u.protocol !== "https:") fail("non-https redirect");
const ok = ["github.com", "objects.githubusercontent.com", "codeload.github.com"];
if (!ok.includes(u.hostname)) fail(`redirect to unexpected host: ${u.hostname}`);3. Checksum parser does not validate the hash shape — npm/scripts/install.js
const entry = sums.split("\n").map((l) => l.trim().split(/\s+/)).find((p) => p[1] === asset);
...
if (got !== entry[0]) fail(...)entry[0] is trusted as the expected digest without asserting it is a 64-char lowercase hex string, and a duplicate filename line silently takes the first match. This fails safe today (a garbage hash just makes the compare fail), but for a security verifier the contract should be explicit:
if (!/^[0-9a-f]{64}$/.test(entry[0])) fail(`malformed checksum entry for ${asset}`);4. (Note, not a required change) Checksum gives integrity, not provenance — npm/scripts/install.js
Both the archive and checksums.txt are fetched from the same release origin (${base}/...), so the sha256 check only proves the archive matches whatever checksums.txt that release currently serves — it catches accidental corruption, not a release-level compromise (leaked maintainer/NPM_TOKEN, malicious goreleaser run). This is the conventional baseline for npm binary wrappers (esbuild/swc do the same), so I'm not blocking on it — but for an OSS supply-chain artifact it's worth a follow-up: have goreleaser sign checksums.txt (signs: / cosign-keyless) and verify the signature in install.js, or bake the per-asset hashes into the published npm tarball at publish time so the trust anchor is the package the user already chose to install. At minimum, document that the checksum is integrity-only.
Non-blocking improvements (P2)
- No retries on transient network failures (
install.js): a single ECONNRESET / momentary 5xx from the CDN callsfail()→ exits → fails the whole install. A 3-attempt backoff for retryable errors/timeouts (but not 404 — fast-fail on wrong version is correct) matches what esbuild/swc do. run.jscollapses signal deaths to exit code 1: when the binary is killed by a signal,spawnSyncreturnsstatus === nulland the shim exits1, losing the conventional128+signum(130 for SIGINT). Callers inspecting$?for Ctrl-C, and tools distinguishing interrupt from error, will misread it. Preferif (res.signal) process.kill(process.pid, res.signal)or exit128+signum.- PATH-hint fallback walks one directory too far (
install.js, thenpm_config_prefix-unset branch):path.resolve(__dirname, "..","..","..","..", isWin ? ".." : "../..")lands one level above the real prefix (e.g. computes/usrinstead of/usr/local), so the printedexport PATH/setxhint points at the wrong dir. Best-effort and never breaks install, but it prints a misleading fix. Drop one level (5..on unix, 4 on Windows), or derive from the bin link instead of counting segments. tar/bsdtar assumed present (install.js): Windows is listed as supported, buttar.exe(bsdtar) only ships on Windows 10 1803+; on Server 2016 / older Win10 (and some minimal Linux containers) it's absent, so extraction fails with a genericextract failed: ... ENOENTafter a verified download. Detect ENOENT and emit a targeted message; optionally a pure-Node zip fallback (yauzl) so Windows doesn't depend on bsdtar.- Concurrency group keyed on
github.ref(npm-publish.yml): areleaserun (refs/tags/v1.2.3) and aworkflow_dispatchof the same version (refs/heads/main) land in different groups, so they don't serialize. Low impact (npm rejects re-publishing the same version; dispatch defaults to--dry-run), butgroup: npm-publish-${{ inputs.tag || github.ref_name }}keys both on the version.
Nits
VERSIONnot sanitized before interpolation (install.js):VERSIONis publisher-controlled (from the package's ownpackage.json), so there's no downstream-consumer injection vector, but the0.0.0guard doesn't reject a malformed version, and a/or..would flow into both the URL andpath.join(binDir, asset). A strict semver assert early closes it defensively.download()buffers the whole body in memory with no size cap: harmless for a few-MB binary; streaming to disk with incremental hashing would bound memory on a hostile redirect.- Hard
process.exit(1)on any failure, no opt-out: correct and loud for a global-gCLI; only relevant if it's ever pulled in transitively in an air-gapped/CI context, where anOCTO_CLI_SKIP_DOWNLOADescape hatch deferring fetch to first run would help. Document the intent.
Verified correct (no action)
- npm asset naming, OS/arch maps,
checksums.txtfilename, and archive root layout all match.goreleaser.yaml. - Windows
tar -xf <zip> -C dir octo-cli.exeextracts a single member correctly with Windows bsdtar. permissions: {}is correct — public checkout works and publish usesNODE_AUTH_TOKEN, notGITHUB_TOKEN.--dry-runwiring: real releases publish (DRY=""), manual dispatch defaults to dry-run — a good safety default.
Requesting changes on #1 (the install hang). #2 and #3 are small and high-value for a security-sensitive installer; the rest are non-blocking and can be addressed at your discretion.
Address review feedback on the postinstall download path and the run-shim's
exit-code propagation. None of these change the happy-path behaviour, only
the failure modes.
install.js
- Per-request timeout (30s) with req.on('timeout') destroy, so a half-open
TCP connection or a stalled CDN can no longer hang `npm install` forever.
- HTTPS-only + host allowlist (github.com, objects.githubusercontent.com,
codeload.github.com) on every redirect hop, so a redirect target outside
GitHub's release-asset path is refused instead of silently fetched.
- Strict semver assertion on VERSION before it is interpolated into the
download URL or the on-disk filename.
- Strict checksum format check (`^[0-9a-f]{64}$`) on the expected digest
parsed from checksums.txt, so a malformed entry is rejected explicitly
instead of relying on the compare to fail.
- 3-attempt exponential backoff (500ms / 1s / 2s) for transient errors
(timeout, socket reset, 5xx). 404 is treated as non-retryable so the wrong
version fails fast.
- Friendly ENOENT message for missing `tar` / `tar.exe` (older Windows,
minimal container images) instead of a bare `extract failed: ENOENT`.
- 200 MiB response-size cap on the buffered body as a defensive guard.
- PATH-hint fallback corrected: when `npm_config_prefix` is unset, the
previous walk-up landed one directory above the real prefix (e.g. `/usr`
instead of `/usr/local`), so the printed `export PATH` hint pointed at the
wrong bin dir. Drops one `..` (5 levels on unix, 4 on Windows).
run.js
- If the binary is killed by a signal, re-raise the same signal so callers
see the conventional 128+signum exit code (e.g. 130 for SIGINT) instead
of a generic 1 that hides Ctrl-C from shells and supervisors.
The `release: published` event fires the moment a release is moved out of draft, but `release-publish.yml` runs `build-artifacts` (goreleaser) only after that, so npm-publish was racing the upload. If publish won the race — or if the artifact build failed — npm would permanently contain a version whose postinstall has nothing to download. Block the publish step until every expected asset is present on the release: the six goreleaser archives (linux/darwin/windows × amd64/arm64, .tar.gz or .zip) plus checksums.txt. Poll with `gh release view` every 15s up to a 30-minute deadline; emit GitHub-Actions-style ::error:: on timeout with the missing-asset list. Other tightening on the same workflow: - concurrency.group keys on `inputs.tag || github.ref_name` instead of `github.ref`, so a release-event run and a workflow_dispatch run for the same version (different ref kinds) still serialize into one group. - Strict semver assertion on the resolved tag before any publish step runs, so a non-release tag pushed by accident can never trigger publish. - `permissions: contents: read` at the job level so the wait step's gh CLI is authorized to enumerate release assets on a public repo; no other token capabilities are granted. - `--dry-run` skips the wait so manual dispatch can be used to validate the workflow against tags that have no real release yet.
Review responses (pushed in 0508a54 + ecd8791)Tried to clear both blockers plus every non-blocking item. @Jerry-Xin
@yujiawei
@lml2468
Commits
Both pushed as additive commits to |
lml2468
left a comment
There was a problem hiding this comment.
Re-review: ecd8791 (previously APPROVED at 4db164a)
Previous Blocking Issues (from Jerry-Xin / Allen)
All three resolved:
-
npm publish ↔ goreleaser race condition ✅ — New "Wait for goreleaser artifacts" step polls
gh release viewfor all 7 expected assets (6 platform archives +checksums.txt) with a 30-minute deadline and 15-second intervals. Correctly skipped on--dry-run. Thecontents: readpermission is minimal and justified. -
Download timeout / size guard ✅ —
REQUEST_TIMEOUT_MS = 30_000,MAX_DOWNLOAD_BYTES = 200 MiB,MAX_RETRIES = 3with exponential backoff (500ms → 1s → 2s). 404 is non-retryable (fast-fail), 5xx and socket errors retry. The redirect host allowlist (ALLOWED_HOSTS) is a solid security addition. -
Fallback prefix path calculation ✅ — Unix: 5 levels (
scripts → pkg → @scope → node_modules → lib → prefix), Windows: 4 levels. Matches npm's actual global layout.
New Improvements (not in previous round)
run.jssignal propagation: re-raises the signal for proper128+signumexit codes instead of generic1— shells and supervisors now seeSIGINTcorrectly.- Semver validation before URL interpolation.
- Checksum format validation (
/^[0-9a-f]{64}$/). tarENOENT handling with platform-specific guidance.- Concurrency key uses
inputs.tag || github.ref_nameso release-triggered and manual runs of the same version serialize properly.
Verified
release-publish.ymlconfirmed:build-artifactsruns goreleaser with--skip=publish,announcethen uploads viagh release upload. The expected asset list in npm-publish matches this output.- The
getOnce→downloadOnce→downloadlayering is clean: redirect-following is separated from retry logic, and each layer has clear single responsibility. assertSafeHostruns on every hop, not just the initial URL.
No remaining concerns. LGTM.
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-cli, but the npm installer will currently reject real GitHub release downloads.
🔴 Blocking
- 🔴 Critical:
npm/scripts/install.js:25-29allowlistsgithub.com,objects.githubusercontent.com, andcodeload.github.com, but GitHub release asset downloads currently redirect torelease-assets.githubusercontent.com. BecausedownloadOnce()validates every redirect atnpm/scripts/install.js:99-103, postinstall will fail before downloading the archive orchecksums.txtatnpm/scripts/install.js:207-208. I verified this against the existingv0.5.0release asset URL, which resolves tohttps://release-assets.githubusercontent.com/.... Addrelease-assets.githubusercontent.comto the allowlist or adjust the redirect validation to match GitHub’s release asset host.
💬 Non-blocking
- 🟡 Warning: Please verify the automatic publish trigger with the existing release flow. The new workflow relies on
on: release: publishedin.github/workflows/npm-publish.yml:14-16, while the repo’s release flow appears to publish releases from.github/workflows/release-publish.ymlusing workflow credentials. If that event is suppressed by GitHub’s workflow-token recursion rules in this setup, npm publishing would only work throughworkflow_dispatch.
✅ Highlights
- The wrapper is appropriately scoped to npm distribution of the existing Go binary.
- The installer validates platform/version, checks SHA-256 before extraction, avoids shell interpolation for archive extraction, and keeps PATH changes as warnings only.
run.jscorrectly forwards stdio/arguments and preserves child exit status/signals.
…wlist GitHub release asset downloads currently 302 to release-assets.githubusercontent.com (a JWT-signed URL CDN), not the older objects.githubusercontent.com. The previous allowlist refused that hop, so the postinstall would fail before downloading the archive or checksums.txt. Verified end-to-end against the v0.5.0 release: the archive now downloads, checksum verification passes, and the extracted binary runs. Keep objects.githubusercontent.com in the allowlist as a fallback in case GitHub reverts or routes some downloads through the older CDN. Reported by @Jerry-Xin on #27.
Round-2 review responses (pushed in 0807a37)@Jerry-Xin
Commit
Plain push, no force. Replied to all of Jerry's items; @lml2468's APPROVED is already on |
lml2468
left a comment
There was a problem hiding this comment.
Re-review: 0807a37 (previously APPROVED at ecd8791)
Blocking Issue from Last Round
ALLOWED_HOSTS missing release-assets.githubusercontent.com ✅ Fixed — Single-line addition with updated comment explaining the CDN history. Verified against real GitHub release redirect chain (github.com → release-assets.githubusercontent.com).
The allowlist now covers the full redirect path:
github.com— initial requestrelease-assets.githubusercontent.com— current CDN for release assetsobjects.githubusercontent.com— legacy CDN (kept for forward compatibility)codeload.github.com— source archives
No other changes. All previous-round fixes (goreleaser wait, timeout/retry, prefix path, signal propagation) remain intact.
LGTM.
Jerry-Xin
left a comment
There was a problem hiding this comment.
Project relevance gate: pass. This PR is in scope for octo-cli, and I found no blocking correctness or security issues.
💬 Non-blocking
-
🟡 Warning: The new npm wrapper has meaningful behavior but no committed automated tests. The highest-value cases are download retry/failure handling, checksum parsing, archive extraction failure, PATH hint output, and
run.jsexit/signal propagation. Relevant areas:npm/scripts/install.js:57,npm/scripts/install.js:134,npm/scripts/install.js:216,npm/scripts/run.js:12. -
🔵 Suggestion: Quote/escape PATH hint values per shell.
fish_add_path ${binDir}and the Windowssetxhint can misbehave when npm’s prefix contains spaces or shell metacharacters. Seenpm/scripts/install.js:164andnpm/scripts/install.js:177.
✅ Highlights
- Checksum verification, HTTPS-only redirect allowlisting, retry/timeout handling, and download size limits are good safeguards in
npm/scripts/install.js. npm-publish.ymlcorrectly waits for GoReleaser artifacts before publishing, uses--access public, and keeps prereleases onnext.run.jspreserves child exit codes and handles missing postinstall binaries cleanly.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Security-focused review of the npm wrapper that downloads and executes a remote Go binary on npm install. The overall design is solid: HTTPS-only, an explicit redirect allowlist re-checked on every hop, SHA-256 verification before extraction, size/redirect/timeout caps, and SHA-pinned CI actions. Two issues should be addressed before merge, plus several non-blocking notes.
Findings
1. (P2) Archive extraction can extract a symlink as the octo-cli member → arbitrary chmod / arbitrary-binary execution
npm/scripts/install.js:236-239
The install extracts only the named member (tar -xzf <tmp> -C binDir octo-cli), which correctly avoids generic zip-slip from other members. However, named-member extraction does not prevent the octo-cli member itself from being a symlink. I verified locally:
ln -s /path/to/victim payload/octo-cli
tar -czf evil.tar.gz -C payload octo-cli
tar -xzf evil.tar.gz -C bin octo-cli # bin/octo-cli is now a symlink
Then fs.chmodSync(path.join(binDir, binName), 0o755) (line 239) follows the symlink and chmods the target. Confirmed: a 0600 file owned by the install user became 0755 through the extracted symlink. Subsequently run.js:12 spawnSyncs bin/octo-cli, which resolves the symlink and executes whatever it points to.
Impact: a malicious archive can (a) chmod any file the install user owns to 0755, and (b) cause octo-cli invocations to execute an arbitrary local executable.
Precondition: the archive must match checksums.txt, so this requires control of the GitHub Release contents (compromised token / malicious release), the same trust root as the binary itself. Hence P2 (defense-in-depth) rather than a remote-unauthenticated bug — but it lets a release-level compromise escalate beyond "run the binary we shipped" to "touch files outside bin/," which is exactly what extraction hardening should prevent.
Fix: pass a flag that refuses symlinks/keeps extraction inside the target. GNU tar and bsdtar both support refusing unsafe members; the most portable approach is to verify the extracted entry is a regular file before chmod:
const st = fs.lstatSync(path.join(binDir, binName));
if (!st.isFile()) fail("archive member 'octo-cli' is not a regular file");Additionally consider tar options to drop symlinks during extraction. Note this is post-checksum, so it is a deliberate "don't let a bad archive escape bin/" guard.
2. (P2) Workflow script-injection: inputs.tag interpolated directly into the publish-job shell, before validation
.github/workflows/npm-publish.yml:60-62
REF="${{ inputs.tag }}"
DRY="${{ inputs.dry_run && '--dry-run' || '' }}"inputs.tag is substituted into the run block as raw text before bash executes. A workflow_dispatch tag value of "; <command>; # breaks out of the assignment and runs arbitrary commands on the runner. Verified the rendered line becomes:
REF=""; echo PWNED; #"
The semver validation at lines 67-71 runs after the unsafe assignment at line 61, so it does not protect this step. Note line 64 already does the right thing on the release path by using the ${GITHUB_REF_NAME} env var — the inputs.tag path is the inconsistent one.
Impact: anyone able to trigger workflow_dispatch (write access) can execute arbitrary code in a job that has secrets.NPM_TOKEN available in a later step → potential npm-token exfiltration / arbitrary package publish. Gated by write access, so P2, but this is the token-handling workflow flagged security-sensitive, so it warrants hardening.
Fix: never interpolate ${{ inputs.* }} / ${{ github.* }} into a run: block. Pass through env: and reference shell variables:
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_DRY: ${{ inputs.dry_run }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
REF="$INPUT_TAG"
[ "$INPUT_DRY" = "true" ] && DRY="--dry-run" || DRY=""
...Same applies to ${{ steps.version.outputs.DRY_RUN }} at line 130 (lower risk since it is internally derived, but move it to env for consistency).
3. (P2 / note) Trust model: checksums.txt is unsigned and co-located with the archive (TOFU/integrity gap)
npm/scripts/install.js:211-227 + release flow
The SHA-256 check protects against transport corruption and partial CDN poisoning, but checksums.txt is fetched from the same release as the archive over the same channel, and is not cryptographically signed (no cosign/minisign). An actor who can write release assets replaces both consistently, and verification passes. The effective trust root is GitHub Release integrity + the npm publish pipeline, not the checksum.
This is acceptable for a v1 and is the common pattern, but it should be a conscious, documented decision rather than an implied guarantee. The code comment "verify its checksum" reads as stronger than the property actually provides. Recommend: (a) document that the trust root is the GitHub Release, and (b) consider signing checksums.txt (cosign keyless) in a follow-up so the npm installer can verify a signature whose key is not co-located with the artifact.
4. (nit) No overall download deadline — slow-trickle DoS from an allowlisted host
npm/scripts/install.js:63, 81-88
timeout: REQUEST_TIMEOUT_MS is an idle socket timeout; it does not fire while bytes keep arriving slowly. A compromised/misbehaving allowlisted CDN could trickle data just under the idle threshold up to ~200 MiB, stalling npm install. Low severity (allowlisted host, capped total bytes). Consider an absolute wall-clock deadline per download in addition to the idle timeout.
5. (nit) Version regex is laxer on the npm side than in CI; permits odd prerelease strings
npm/scripts/install.js:197 vs .github/workflows/npm-publish.yml:68
The installer regex accepts +build metadata (1.2.3+build) and prerelease strings like 1.2.3-.., while the CI regex (line 68) rejects +build. Because VERSION originates from the published package.json (publisher-controlled) and these strings are embedded mid-filename (octo-cli_<v>_<os>_<arch>, not a standalone path segment), I could not turn this into path traversal — path.join(binDir, asset) stays inside binDir. Still, aligning the two regexes (and rejecting +build, since CI never produces it) removes a latent inconsistency. Not blocking.
6. (nit) Disallowed-host / malformed-redirect errors are retried 3×
npm/scripts/install.js:119-122
assertSafeHost and new URL(...) throw errors without httpStatus, so status === undefined makes them retryable. A redirect to a hostile host is correctly rejected, but only after 3 attempts + backoff. Cosmetic (wastes a few seconds, no security impact); consider marking allowlist/parse failures as non-retryable.
Non-security notes
entry.length < 2(line 221) is dead code:.find(p => p[1] === asset)already guarantees length ≥ 2 when matched. Harmless.getOncesize-overflow relies onreq.destroy(err)emitting'error'to reject the promise; this holds on the supported Node ≥ 18 floor. Fine.run.jssignal re-raise and exit-code propagation look correct.- 404 fast-fail (non-retryable) for missing release assets is the right call.
Verdict
Requesting changes for findings #1 and #2 — both are concrete hardening gaps on a script that executes a remote binary and handles NPM_TOKEN. Neither is a remote-unauthenticated RCE (both are gated behind release/write-access compromise), but they are exactly the escalation paths a security-sensitive review should close. #3 should at minimum be documented. The rest are nits.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Thanks for this — the wrapper is genuinely well thought through. SHA‑pinned actions, top‑level permissions: {}, the 0.0.0 placeholder guard, the prerelease→@next dist‑tag rule, strict semver validation of the version before it touches a URL or a path, the host allowlist re‑checked on every redirect hop, the size/redirect/timeout/retry caps, and the checksum‑before‑extract flow are all done right. The asset naming also matches .goreleaser.yaml. Most of my findings are on the publish workflow, not the installer.
This PR is security‑sensitive (a postinstall that downloads and executes a remote binary, and a workflow that handles NPM_TOKEN), so I reviewed it accordingly. There is one code‑level blocker in the diff.
Blocking (P1)
1. Script injection: ${{ inputs.tag }} interpolated into a run: block before validation
.github/workflows/npm-publish.yml:67 (inside the "Resolve version and dist-tag" step)
REF="${{ inputs.tag }}"The Actions expression engine substitutes inputs.tag as literal text into the shell source before bash parses the line. The semver regex at :74 runs against $VERSION only after this assignment has already executed, so it cannot gate the injection. A workflow_dispatch tag of:
v1.0.0"; curl -s https://attacker/x.sh | bash; echo "
renders the line as REF="v1.0.0"; curl -s ... | bash; echo "" and runs arbitrary commands on the runner. The injected code can plant a malicious .npmrc or background process that hijacks the later Publish to npm step, which holds secrets.NPM_TOKEN — i.e. token abuse / poisoned‑package publish (and that package itself executes a remote binary on install).
Note this is inconsistent with the very next branch: :70 already does it the safe way via REF="${GITHUB_REF_NAME}".
Severity: triggering requires repo write (workflow_dispatch is not reachable from forks/anonymous actors), which keeps it out of the "remote unauth" P0 band — but for a security‑sensitive token‑bearing workflow this is a textbook injection sink (CWE‑94) with a one‑line standard fix, and it will trip GHA linters (zizmor/actionlint). Please fix before merge.
Fix — pass untrusted inputs through env: and reference the shell variable (which is not re‑parsed as code):
- name: Resolve version and dist-tag
id: version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_DRY: ${{ inputs.dry_run && '--dry-run' || '' }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
REF="$INPUT_TAG"; DRY="$INPUT_DRY"
else
REF="${GITHUB_REF_NAME}"; DRY=""
fi
...Also move the ${{ steps.version.outputs.DRY_RUN }} interpolation on :136 into env: for the same reason (it is currently only ever --dry-run/empty, so not exploitable today, but keep the pattern consistent).
2. NPM_TOKEN-bearing publish has no protected Environment
.github/workflows/npm-publish.yml job publish (:44) + Publish step (:133-136), trigger workflow_dispatch (:23)
The job that consumes NPM_TOKEN is not bound to a protected GitHub Environment (no environment: key). workflow_dispatch is available to anyone with repo write, so there is no required‑reviewer / wait‑timer gate between an arbitrary dispatch and a real npm publish with the production token. Publish authority is effectively coupled to commit authority; combined with finding #1 this becomes package takeover, but it stands on its own even without the injection.
Fix: bind the job to an Environment with required reviewers, e.g. environment: npm-release, configured in repo settings. (This is partly a repo‑settings change, so I'm flagging it as strongly recommended hardening rather than a pure‑diff blocker — but for a token‑bearing publish path it's the canonical control and worth doing alongside #1.)
Non-blocking — recommended hardening (P2)
- npm provenance (
:136): the package runs apostinstallthat downloads+executes a binary, so consumers benefit from a cryptographic origin link for the tarball itself (the checksum only protects the downloaded binary, notinstall.js). Addid-token: writeto the job permissions and--provenancetonpm publish(npm ≥ 9.5, public repo — both satisfied here). - Symlink guard on the extracted member (
install.js:483-489): named‑member extraction (tar … octo-cli) prevents zip‑slip from other members, but theocto-climember could itself be a symlink —fs.chmodSyncand the laterspawnSyncwould follow it. This is fully behind the GitHub‑release trust root (an actor who can matchchecksums.txtcould just ship a malicious regular‑file binary), so it's defense‑in‑depth, but cheap and zero‑regression since goreleaser never emits symlink members:const st = fs.lstatSync(path.join(binDir, binName)); if (!st.isFile()) fail("archive member 'octo-cli' is not a regular file");
Nits (optional)
npm/package.jsondeclares"license": "Apache-2.0"but noLICENSEexists at the npm package root (npm/), so the published tarball ships the declaration without the license text (Apache‑2.0 §4). Addnpm/LICENSE.- Installer semver regex (
install.js:447) is laxer than the CI regex (npm-publish.yml:74) — it accepts+build. Harmless for traversal (no/allowed, version sits mid‑filename), but worth keeping in sync. checksums.txtis unsigned and co‑located with the archive — this is the standard goreleaser trust model (trust root = GitHub release integrity), but theinstall.jscomment "verifies its checksum" slightly overstates the guarantee. Consider a wording tweak and/or cosign as a future follow‑up.run.js:33-34SIGNUMStable is incomplete; the128 + (SIGNUMS[res.signal] || 0)fallback returns128for unmapped signals (e.g. SIGPIPE/SIGUSR1). Preferrequire("os").constants.signals[res.signal]. Cosmetic — the explicit re‑raise on:30handles the common cases.- No idle‑vs‑wall‑clock download deadline (slow‑trickle could stall
npm install, capped by 200 MiB / allowlisted host); allowlist/parse rejections are retried 3× before failing fast. Minor robustness.
Summary
The installer and shim are solid. The blocker is the workflow script‑injection at npm-publish.yml:67 (#1) — a real sink in a token‑bearing publish job, with a trivial env:‑based fix. Please also consider the Environment gate (#2) and provenance/symlink hardening for this security‑sensitive package. Once #1 is fixed I'd be happy to re‑review.
Round-2 (yujiawei) flagged a symlink-in-archive escape on the install path
and a script-injection sink in the publish workflow; round-3 (Linus) flagged
the npm-publish trigger as non-functional under GitHub's recursion rule and
the PATH-hint fallback as fragile. This consolidates the fixes plus the
non-blocking hardening from the same rounds. No behaviour change on the
happy path; only the failure / hostile-input paths.
install.js
- Symlink guard: lstatSync the extracted member before chmodSync. tar's
named-member extraction does not prevent the member itself from being a
symlink; the previous chmodSync + later spawnSync would follow it,
letting a malicious archive chmod files outside bin/ and execute an
arbitrary local binary as octo-cli. PoC verified locally; the new guard
refuses non-regular files and unlinks the symlink before failing.
- PATH hint rewrite: stop counting `..` from __dirname to guess <prefix>.
Instead, if npm's bin is already on PATH, do nothing; otherwise hint our
own bin/ (path.join(__dirname, "..", "bin")) which is exact. Cost: the
user only fixes octo-cli, not other global packages — but if npm's bin
isn't on PATH they have that problem repo-wide anyway.
- Wall-clock deadline: REQUEST_TIMEOUT_MS is an idle timeout; a slow-trickle
peer can stay under it and stall the install. Adds a 5-minute wall-clock
cap across all retries.
- Non-retryable error classification: allowlist / parse / deadline /
oversize errors get `nonRetryable: true` so they fail fast instead of
burning 3 attempts.
- Host allowlist now includes release-assets.githubusercontent.com (the CDN
GitHub release assets currently redirect to); kept
objects.githubusercontent.com as a fallback.
- 200 MiB body size cap (defensive, far above any real archive).
- Strict semver assertion on VERSION before it is interpolated into URL or
filename. Aligned with the workflow's tag regex (no +build).
- Checksum entry: validate digest shape (^[0-9a-f]{64}$) explicitly rather
than relying on the compare to fail.
- ENOENT for `tar` now emits a targeted message (older Windows / minimal
containers without bsdtar) instead of a bare "extract failed: ENOENT".
- Extracted nonRetryable, assertSafeHost, isValidReleaseSemver, assetName,
parseChecksumEntry, ALLOWED_HOSTS as named exports so the test file can
cover them directly.
- BIN_NAME constant at module scope so the platform-suffix special case
doesn't repeat across maybeHintPath and main.
- Trust-model + failure-behaviour paragraphs at the top of the file so a
future reviewer doesn't have to reconstruct the "checksum is integrity,
not provenance" and "hard exit is deliberate, no SKIP_DOWNLOAD opt-out"
decisions from scratch.
run.js
- Signal propagation: re-raise the killing signal with process.kill so
callers see 128+signum (e.g. 130 for SIGINT) instead of a generic 1.
Uses os.constants.signals for the fallback so signals outside a small
hand-coded set (SIGPIPE, SIGUSR1, ...) also get a faithful exit code.
npm-publish.yml
- Script-injection fix: all `${{ inputs.* }}` / `${{ steps.* }}` are now
routed through `env:` shell variables. The previous direct interpolation
into `run:` was substituted as literal text before bash parsed the line,
letting a crafted tag value execute arbitrary commands in a step that
later holds NPM_TOKEN.
- npm provenance: `id-token: write` + `--provenance` on publish. This is
the only origin link consumers get for the wrapper tarball itself; the
binary's sha256 is independent.
- Trigger chain rewrite: removes `on: release: published`. That event is
fired by GITHUB_TOKEN in release-publish.yml; GitHub's recursion rule
suppresses workflow runs for events caused by GITHUB_TOKEN, so the
trigger never actually delivered. release-publish.yml now invokes us via
`gh workflow run` (workflow_dispatch is exempt from the suppression).
Removes the 30-min `wait-for-artifacts` poll (and its 7 hard-coded asset
filenames): with explicit invocation, sequencing is by step order, not
by polling a remote state.
- Strict semver assertion on the resolved tag before any publish step runs.
- Concurrency group keyed on `inputs.tag` so two dispatches of the same
version serialize.
release-publish.yml
- `actions: write` on build-artifacts so the new "Trigger npm publish"
step can `gh workflow run npm-publish.yml -f tag=$TAG -f dry_run=false`
after archives are uploaded. Sequenced after `gh release upload` so the
archives + checksums.txt are guaranteed present when npm-publish runs.
package.json
- `files:` now lists the two scripts explicitly (`install.js`, `run.js`)
so the upcoming `install.test.js` is not shipped in the published tarball.
- `LICENSE` added to the file list (and a copy lives at npm/LICENSE) so
the published tarball carries the Apache-2.0 text alongside its
declaration, satisfying §4.
README
- Trust-model section: explicit about sha256 being integrity, not
provenance; mentions `npm audit signatures` to verify the package's npm
provenance attestation; flags signing checksums.txt with cosign as a
follow-up.
Verified
- Live e2e: install.js downloads v0.5.0 archives from real GitHub Release,
passes checksum, extracts, runs the binary. (Asset prefix patched
octo-cli_ → octo_ for the test, since v0.5.0 predates the rename.)
- Symlink PoC: constructed a tarball whose `octo` member is a symlink to a
0600 victim file; new guard refuses extraction, victim's mode preserved.
- All `${{ }}` interpolations now in `env:`; YAML parser reports zero
expression-language sinks in any `run:` block across both workflows.
The download/extract paths are covered by a manual e2e against real GitHub releases. This adds regression protection for the smaller pure functions — parsing, validation, host allowlist, PATH-hint scenarios — where future edits are most likely to land. scripts/install.test.js (32 tests, ~60ms) - assertSafeHost: allowlist passes / non-https rejection / off-list host rejection / errors marked nonRetryable / ALLOWED_HOSTS set is locked (so adding or removing a host shows up in tests, not silently) - isValidReleaseSemver: stable releases / prereleases / rejects +build metadata / rejects malformed / non-string inputs / 0.0.0 is structurally valid (placeholder rejection lives in main(), not in the validator) - assetName: locked to the 6-platform grid that matches .goreleaser.yaml's name_template (a goreleaser rename or platform change trips this) - parseChecksumEntry: well-formed entry / surrounding whitespace / missing asset / non-hex / uppercase hex / wrong length - maybeHintPath: 5 PATH scenarios + 4 shell dialects + npm_config_location ci.yml - New `npm-test` job runs in parallel with build, on a clean setup-node@v4 / Node 20 (the test code uses node:test built in since 18, but Node 20 is the floor we recommend for CI). package.json - `"test": "node --test scripts/install.test.js"` so `npm test` runs the suite locally too.
Round 3 — addressed all blockers + Linus-style reviewPushed two commits:
Plain push (additive), no force. Round-2 review responses@yujiawei (4394175208 + 4394180019)
Linus-style review responsesI asked a Linus-style review to look at the whole branch (committed + round-3 unstaged). It surfaced two structural issues the security-focused reviews didn't:
Plus a regression-protection layer:
Verified
Still to do (not in this PR)
|
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is in scope for octo-cli and the npm wrapper/release changes look merge-ready.
💬 Non-blocking
- 🔵 Suggestion —
.github/workflows/npm-publish.yml:60-96: the resolver normalizes bothv0.6.0and0.6.0, but checkout happens before normalization using the raw input. A manualtag=0.6.0run can fail at checkout unless that exact ref exists. Consider either requiringvexplicitly or checking outsteps.version.outputs.TAGafter resolution. - 🟡 Warning —
.github/workflows/release-publish.yml:99-103:gh workflow runonly dispatchesnpm-publish.yml; it does not wait for the npm publish result. A release workflow can finish green while npm publication fails separately. This is acceptable if monitored as a separate workflow, but worth making explicit in release operations. - 🔵 Suggestion —
npm/scripts/install.js:126-246: the GitHub release downloader does not appear to honor npm proxy settings orHTTPS_PROXY. That may matter for corporate/agent environments where npm registry access works through configured proxy but direct GitHub downloads do not.
✅ Highlights
- Strong install-time hardening: HTTPS host allowlist, redirect limits, byte cap, wall-clock deadline, checksum verification, and regular-file validation after extraction.
- Release/npm workflow hardening is thoughtful: SHA-pinned actions, scoped permissions, release-existence gate, prerelease
nextdist-tag handling, and npm provenance. - Local verification passed:
node --test scripts/install.test.js,node --check scripts/install.js && node --check scripts/run.js,go test ./..., andnpm pack --dry-run.
Dismissing: this APPROVE incorrectly downgraded the checkout-before-normalization supply-chain bypass to non-blocking. The root cause identified in round 6 (raw inputs.tag in checkout) remains unfixed. Re-submitting with correct verdict.
Jerry-Xin
left a comment
There was a problem hiding this comment.
Re-review at 3fd1cc630cc8 — correcting my earlier (dismissed) APPROVE. The new footgun guards are welcome but the root-cause supply-chain bypass from round 6 remains open.
🔴 Blocking
- 🔴 Critical —
.github/workflows/npm-publish.ymlL60:actions/checkoutstill uses raw${{ inputs.tag }}. The resolver (L78–96) normalizes toTAG=v$VERSIONafter checkout, so a dispatch withtag=0.6.0(novprefix) checks out whatever ref0.6.0resolves to — which could be an attacker-created branch with malicious code — while the release gate validates the legitimate published release forv0.6.0. The newtagName == TAGequality check (L119) does not help because it compares the release's tag name against the normalizedTAG, not against the actually-checked-out ref. Net result: malicious code ships to npm with valid Sigstore provenance. - 🔴 Critical — Concurrency key (
.github/workflows/npm-publish.ymlL33) uses rawinputs.tag, sonpm-publish-v0.6.0andnpm-publish-0.6.0are distinct groups and do not serialize. A race between the legitimate dispatch and the attacker's dispatch would not be caught.
✅ Acknowledged improvements in this commit
- ✅ Empty-TAG guard (L107–110) prevents
gh release view ""from silently resolving to the latest release. - ✅
tagNameequality check (L117–120) prevents futureghCLI fallback behavior from validating an unrelated release.
💡 Fix — any one of these closes the gap:
- Reject non-
vinput in the resolver:[[ "$REF" =~ ^v ]] || { echo "::error::Tag must start with v"; exit 1; }— narrowest change, zero structural impact. - Checkout the normalized ref: move checkout after the resolver and use
ref: refs/tags/${{ steps.version.outputs.TAG }}— structurally cleaner but requires a two-step layout change. - Post-checkout SHA assertion: after checkout, compare
git rev-parse HEADagainst the tag target SHA from the release API — defense-in-depth but more complex.
Option 1 is recommended; it closes both the checkout and concurrency-key issues (non-v input never reaches either path).
Both yujiawei and lml2468 independently flagged the same deeper hole:
the release gate's TAG was the v-normalized form (\`v\${VERSION}\`),
but \`actions/checkout\` was pinned with raw \`\${{ inputs.tag }}\`.
Attack: a write-capable actor creates a branch named \`0.6.0\` containing
malicious code, dispatches npm-publish.yml with \`tag=0.6.0\` and
\`dry_run=false\`. checkout pulls the branch (raw tag), Resolve normalizes
to TAG=v0.6.0, the gate's \`gh release view "v0.6.0"\` passes against the
legitimate release, the tagName-equality check passes (both sides are
v-prefixed), and npm publish ships the branch content with valid
Sigstore provenance.
Two changes, defense-in-depth:
1. **Strict v-prefix on the input.** Resolve now rejects bare semver
(\`0.6.0\`) and branch-ish refs (\`main\`). The check happens before
any filesystem operation, so attacker content never lands in the
workspace. Resolve was moved ahead of checkout (it only reads inputs
and writes outputs, no checkout dependency) and given
\`working-directory: .\` to override the job-level npm/ default that
doesn't exist yet.
2. **\`refs/tags/\` checkout prefix.** Even with the v-prefix enforced,
a same-named branch could in theory shadow a tag. \`refs/tags/\`
disambiguates: only an actual tag in the tags namespace resolves.
The two together make the contract: input must carry the 'v', and the
ref must be a tag in the tags namespace. Combined with the previous
round's empty-TAG guard and tagName-equality check, the gate now
validates the same release the checkout will package, with no
normalization seam left to exploit.
Smoke-tested input rejection against `0.6.0`, `main`, empty, and an
injection-style payload. All correctly rejected. `v0.6.0` and
`v0.6.0-rc.1` accepted.
Reported by yujiawei (P0) and lml2468 (Blocking) on #27.
Round 8 — closed the input-normalization gapPushed `e3e5e84`. You're both right — the previous round's footgun guards (empty-TAG bail and tagName equality) closed the gh-CLI-fallback hole, but left the normalization seam wide open. Specifically: checkout was using raw `${{ inputs.tag }}`, while the gate validated the v-normalized form. A dispatch with `tag=0.6.0` would checkout whatever `0.6.0` resolves to (an attacker-controlled branch named `0.6.0`) and the gate would happily validate the legitimate `v0.6.0` release. Two changes, defense-in-depth (lml2468's options 1 and 2 from her review): 1. Strict v-prefix on the input`Resolve` now rejects bare semver (`0.6.0`) and branch-ish refs (`main`, `HEAD`, etc.). The check happens before any filesystem operation — attacker content can never reach the workspace if the input is rejected. Resolve was moved ahead of checkout (it only reads inputs / writes outputs, no checkout dependency) with `working-directory: .` overriding the job-level `npm` default. 2. `refs/tags/` checkout prefixEven with the v-prefix enforced, a same-named branch could theoretically shadow a tag depending on actions/checkout's resolution order. `refs/tags/${{ steps.version.outputs.TAG }}` disambiguates: only an actual tag in the tags namespace resolves. The contract is now
Smoke-tested``` What's still NOT in this PR (acknowledged follow-ups)
Verified
|
lml2468
left a comment
There was a problem hiding this comment.
✅ Re-review APPROVED — octo-cli #27 (e3e5e84)
feat(npm): add @mininglamp-oss/octo-cli npm wrapper
All three blocking issues from my previous rounds are resolved:
-
Resolver before checkout — validation now runs before
actions/checkout(withworking-directory: .override). Rejected inputs never reach the checkout step, so attacker-supplied refs cannot land content in the workspace before validation. -
Bare semver rejected — regex changed from
^[0-9]+\.to^v[0-9]+\., enforcingv-prefix.tag=0.6.0is now rejected with a clear error message explaining the branch/tag confusion risk. -
Checkout pinned to tag ref —
ref: refs/tags/${{ steps.version.outputs.TAG }}explicitly resolves to the tag, not a same-named branch. Combined with the v-prefix enforcement, the checkout ref and release gate now use the same canonical value.
Previously resolved items still intact:
- ✅ Empty TAG bailout guard
- ✅ tagName equality check (belt-and-braces)
- ✅ install.js cleanup refactor (tmp archive unlinked before fail())
- ✅ .goreleaser.yaml sha256 pinning
- ✅ $DRY_RUN SC2086 fix
CI green: actionlint, npm test, build & test, Analyze (actions/go).
Jerry-Xin
left a comment
There was a problem hiding this comment.
In scope for octo-cli, but the release-to-npm handoff has a blocking supply-chain ref ambiguity.
🔴 Blocking
🔴 Critical — .github/workflows/release-publish.yml:51-55 checks out ${{ inputs.tag }} as an unqualified ref before building the release artifacts. The new npm installer trusts those artifacts and checksums.txt, so if a branch and tag share the same name, actions/checkout can resolve the branch while the release evidence still refers to the intended tag. This can publish binaries built from the wrong commit. Use an unambiguous tag ref here, e.g. ref: refs/tags/${{ inputs.tag }}, after validating the tag format before checkout.
🔴 Critical — .github/workflows/release-publish.yml:99-103 dispatches npm-publish.yml with --ref "$TAG", again using an unqualified attacker-controllable ref name. The comment says this pins the publish workflow to the tag, but a same-named branch can defeat that assumption depending on ref resolution. Avoid dispatching the publish workflow from an ambiguous ref; use a trusted workflow ref plus the existing refs/tags/... checkout inside npm-publish.yml, or otherwise resolve and pass only an unambiguous tag reference supported by GitHub’s dispatch API.
✅ Highlights
The npm wrapper itself is generally careful: host allowlisting, HTTPS-only downloads, bounded retries, checksum validation, named-member extraction, and regular-file checks are all good controls.
Local verification passed: node --test scripts/install.test.js, node --check scripts/install.js, node --check scripts/run.js, npm pack --dry-run, and git diff --check main...HEAD.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Verdict: APPROVED
A thin npm wrapper that downloads the matching prebuilt Go binary on postinstall, verifies its sha256 against the release checksums.txt, and execs it. This is a supply-chain-sensitive surface (postinstall script + network fetch + extraction running on every consumer machine), and the implementation handles it carefully. I reviewed it as a security-sensitive change and found no blocking (P0/P1) issues. A few P2/nit suggestions below — none of them gate the merge.
What I verified
- ✅ Unit tests pass —
node --test scripts/install.test.js→ 35/35 pass.node --checkclean on bothinstall.jsandrun.js. - ✅ Zero third-party dependencies —
package.jsonhas nodependencies/devDependencies;install.jsandrun.jsuse only Node builtins (fs,path,https,crypto,child_process,os). Minimal install-time attack surface. - ✅ Archive naming is in lockstep with goreleaser —
assetName()producesocto-cli_<version>_<os>_<arch>.{tar.gz|zip}, matching.goreleaser.yaml'sname_templateandformat_overrides. OS/arch mapping (win32→windows,x64→amd64) is correct. The binary sits at the archive root (nowrap_in_directory), so the named-member extraction targets the right path. - ✅ Redirect allowlist (
install.js:702-736) — onlygithub.com+ the GitHub asset CDNs are followed; non-https and off-allowlist hosts throw a non-retryable error before any bytes land.MAX_REDIRECTS, idle timeout, wall-clock deadline, and a 200 MiB size cap are all enforced. - ✅ Checksum parsing is strict (
install.js:758-769) — requires^[0-9a-f]{64}$, rejecting missing/malformed/uppercase/wrong-length digests. The 64-hex invariant is pinned to.goreleaser.yaml'salgorithm: sha256with cross-referencing comments. - ✅ Extraction hardening (
install.js:1016-1044) — extracts only the single named binary member (mitigates zip-slip from sibling members), thenlstats the result and refuses anything that isn't a regular file (mitigates a symlink member being chmod'd/exec'd). Defense-in-depth behind the release trust root. - ✅ Workflow command-injection guard —
npm-publish.ymlreadsinputs.tagviaenv:(INPUT_TAG) rather than${{ }}interpolation in therun:block, correctly avoiding CWE-94 in a job that later hasNPM_TOKENin scope.NPM_TOKENis only in scope in the finalPublish to npmstep, not in the input-parsing step. - ✅ Publish gate —
npm-publish.ymlrefuses to publish unlesstagresolves to a real, non-draft GitHub Release whosetagNameexactly matches the requested tag (with explicit guards againstgh release view ""silently resolving to "latest"). Checkout pinsrefs/tags/...to defeat branch/tag name confusion. Tag-format regex is kept consistent betweenrelease-publish.ymlandnpm-publish.yml. - ✅ Signal propagation in the shim (
run.js) — re-raises terminating signals and falls back to128+signumfor default-ignored signals, so callers/supervisors observe conventional exit codes.
Findings
P2 / Suggestions (non-blocking)
-
checksums.txthas no independent signature. The sha256 verification proves the archive matches whateverchecksums.txtthe same release serves; both share one trust root (GitHub Release write access), so an actor who can write to a release can replace both consistently. This is integrity, not provenance — and the code/README are admirably explicit about it (install.js:664-671,npm/README.md"Trust model", future cosign note). No change requested; flagging so a human reviewer can confirm the trust model is acceptable for the distribution channel. The wrapper tarball itself does get--provenance(Sigstore), which is the right call. -
Out-of-band items a human should confirm (cannot be verified from the diff): (a) ownership of the
@mininglamp-ossnpm scope, and (b) thatNPM_TOKENis correctly scoped to publish only this package. The PR notes these were handled separately. Additionally, the publish gate's strength rests on release creation being restricted to trusted maintainers — worth a sanity check thatrepo:writealone can't both cut a published release and dispatch the publish workflow. -
Nit —
tar.exeavailability on Windows. Extraction shells out totarfor both.tar.gzand.zip. The code already detectsENOENTand prints a targeted message pointing at Windows 10 1803+ / Git for Windows / 7-Zip, which is the right UX. No action needed; noting that the zip path depends on the host's bsdtar supporting single-member extraction from zip (it does on supported Windows builds). -
Nit — no
--ignore-scriptsfallback. If a consumer installs with--ignore-scripts, the postinstall never runs and the binary is absent;run.jsthen surfaces a clear "binary not found — try reinstalling" message rather than a bare ENOENT. That's acceptable handling; just calling out the expected behavior.
Summary
Clean, dependency-free, and the security-relevant edges (host allowlist, size/time caps, strict checksum format, named-member + non-regular-file extraction guards, workflow input handling, and the publish gate) are all covered with tests and explanatory comments. The trust-model limitation is honestly documented rather than glossed over. Approving.
The previous round closed the input-normalization / tag-vs-branch gap in
\`npm-publish.yml\`, but Jerry-Xin pointed out the same shape of attack
existed in \`release-publish.yml\`'s build-artifacts job: both the
checkout and the cross-workflow dispatch were using raw \`inputs.tag\` /
raw \`\$TAG\`, which actions/checkout and gh CLI both resolve as branch-
or-tag. An attacker with a branch name colliding with the tag (e.g. a
\`v0.6.0\` branch) could:
(a) sneak malicious code into the goreleaser build by intercepting
the Checkout step's ref resolution; or
(b) force \`gh workflow run --ref \"\$TAG\"\` to pull a poisoned
version of npm-publish.yml itself when dispatching the npm
publish — even though npm-publish.yml's own checkout was already
pinned to refs/tags/.
Three changes mirroring the npm-publish hardening:
1. **Validate-before-checkout.** New first step rejects non-v-prefixed
inputs (\`0.6.0\`, \`main\`, injection payloads) before any
filesystem operation. The previous \"Validate tag before upload\"
step ran after the privileged GoReleaser build and after checkout
— too late.
2. **refs/tags/ checkout prefix.** \`ref: refs/tags/\${{ inputs.tag }}\`
forces the tags namespace, so a same-named branch can't shadow the
tag.
3. **Trusted workflow ref for dispatch.** Dropping the \`--ref \"\$TAG\"\`
argument lets gh take npm-publish.yml from the repo's default branch
instead of the (potentially fake) tag. The packaging contents are
still pinned by npm-publish.yml's own \`refs/tags/\` checkout, so
the published tarball still matches the release commit. This
separates \"which workflow file runs\" (trusted) from \"which source
it packages\" (release tag).
This brings release-publish.yml's posture in line with npm-publish.yml,
which is the only sensible end state — the two workflows are one
security boundary.
Verified
- actionlint clean on all 3 workflows
- 35 npm tests still pass
- step order: Validate → Checkout(refs/tags/) → Build → Upload → Dispatch
Reported by Jerry-Xin on #27.
Round 9 — extended ref-ambiguity hardening to release-publish.ymlPushed `81a5752`. You're right — npm-publish.yml was hardened in round 8 but release-publish.yml was left soft, even though they're one security boundary. Three changes mirroring the npm-publish fix: 1. Validate-before-checkoutNew first step rejects non-v-prefixed inputs (`0.6.0`, `main`, injection payloads) before any filesystem operation. The previous "Validate tag before upload" ran after both the privileged GoReleaser build and the checkout — too late, attacker content would already have built and signed. 2. `refs/tags/` checkout prefix`ref: refs/tags/${{ inputs.tag }}` forces the tags namespace — a same-named branch can't shadow the tag in actions/checkout's ref resolution. 3. Trusted workflow ref for dispatchDropped `--ref "$TAG"` from `gh workflow run`. The dispatch now defaults to the repo's default branch, so the npm-publish.yml definition is taken from a trusted ref instead of the (potentially fake) tag. The packaging contents are still pinned by npm-publish.yml's own `refs/tags/` checkout, so the published tarball still matches the release commit. This separates two concerns that should never have been conflated:
Step order, build-artifacts```
Verified
cc @Jerry-Xin |
lml2468
left a comment
There was a problem hiding this comment.
✅ Re-review APPROVED — octo-cli #27 (81a5752)
feat(npm): add @mininglamp-oss/octo-cli npm wrapper
This commit closes the remaining release-publish.yml supply-chain gaps that Allen flagged in round 8:
-
Pre-checkout tag validation — new step enforces v-prefixed semver BEFORE checkout, matching npm-publish.yml's resolver regex. Rejected inputs never land in the workspace.
-
Checkout pinned to tag namespace —
ref: refs/tags/${{ inputs.tag }}replaces the bareref: ${{ inputs.tag }}, eliminating branch/tag ambiguity. Same fix pattern as npm-publish.yml. -
Dispatch without
--ref—gh workflow run npm-publish.ymlnow dispatches against the default branch (trusted workflow file) instead of--ref "$TAG". The packaging contents are separately pinned by npm-publish.yml's ownrefs/tags/checkout. Good separation: "which workflow runs" (trusted) vs "which source it packages" (release tag). -
Old post-checkout validation removed — the "Validate tag before upload" step is superseded by the pre-checkout validation, reducing redundancy.
All items from every review round are now resolved. CI green.
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-cli and is ready to merge; I found no blocking correctness or security issues.
💬 Non-blocking
🟡 Warning: npm/scripts/install.js:126 uses raw https.get, so it will not automatically honor npm proxy settings such as corporate proxy/cafile configuration. Installs may fail in environments where npm install itself works only because npm is proxy-configured. Consider supporting HTTPS_PROXY / HTTP_PROXY / NO_PROXY, or documenting that direct GitHub HTTPS access is required.
🔵 Suggestion: npm/scripts/install.js:347 through npm/scripts/install.js:365 handles the highest-risk postinstall path, but tests currently cover parsing, platform naming, host checks, deadline behavior, and PATH hints rather than archive extraction. A small fixture-based tar/zip extraction test would give useful regression coverage for the tar invocation and expected binary member name.
✅ Highlights
The release/package flow is well aligned with the project: GoReleaser artifact naming, npm version substitution, dist-tag handling, and the package shim all match the intended octo-cli_<version>_<os>_<arch> release layout.
The installer has good defensive checks: strict platform/version validation, redirect host allowlisting, sha256 verification, bounded download size/time, named-member extraction, and a regular-file check before chmod/execution.
Verified locally: npm test, npm pack --dry-run, go test ./..., and git diff --check main...HEAD all passed.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Independent, security-focused review of the @mininglamp-oss/octo-cli npm wrapper. This is a supply-chain–sensitive surface: a postinstall script downloads a prebuilt Go binary on every npm install -g, verifies its sha256, and execs it; two GitHub Actions workflows publish the package. I read the full download → verify → extract path, both publish workflows, and the cross-file contracts (goreleaser ↔ installer ↔ package.json ↔ tests) at head 81a5752, and adversarially re-verified each finding against the actual code.
Verdict: APPROVED. No P0/P1 (blocking) issues. The five items below are P2 — non-blocking, and every one fails safe.
What's done right (verified, not assumed)
- Download path: per-hop host allowlist re-checked on every redirect including the first request, https-only (http downgrade rejected),
MAX_REDIRECTS/MAX_DOWNLOAD_BYTEScaps, dual idle + wall-clock deadlines (the wall-clock timer is cleared oncloseso it can't hang Node), and correct retry classification (4xx incl. 404 non-retryable, only 5xx/undefined retried, exactly 3 attempts). - Integrity:
parseChecksumEntrydoes an exact filename match and enforces^[0-9a-f]{64}$, so a garbage line that merely matches by filename is rejected. sha256 is computed over the in-memory buffer and compared before the bytes are written/extracted — no TOCTOU between verify and extract. - Extraction: named-member extraction prevents zip-slip from sibling members;
lstatSync+isFile()refuses a symlink/dir/device member beforechmod, so a malicious member can't redirectchmod/exec. - Version safety: the
0.0.0placeholder guard and strictisValidReleaseSemverboth run beforeVERSIONtouches a URL or a filesystem path (the regex precludes/,.., spaces). - Workflows: dispatch inputs are read via
env:and never interpolated into arun:shell body (CWE-94 avoided); both checkouts use therefs/tags/prefix and validate before checkout (branch/tag-confusion closed); the release-existence gate binds to the requested$TAG, guards empty-TAG, assertstagNameequality, and checksisDraft;permissions: {}at top level with least-privilege jobs; all third-party actions SHA-pinned; trusted-workflow-ref vs packaged-release-ref separation is correct. - Contracts:
assetName()exactly matches goreleaser'sname_templateand os/arch tokens;package.jsonos × cpu(6 combos) equals goreleaser's build matrix — nothing uninstallable, nothing built-but-unlisted; sha256 is pinned in goreleaser in lockstep with the installer regex;run.jspropagates exit codes and signals (128+signum) correctly; thefilesallowlist ships only the wrapper and excludesbin/.
Non-blocking findings (P2)
-
Decompression-bomb size on extract is unbounded —
npm/scripts/install.js:363-364.MAX_DOWNLOAD_BYTEScaps the compressed bytes received over the wire, buttar -xzf/-xfdecompresses the named member with no size cap, so a crafted archive whoseocto-climember is a compression bomb could exhaust disk during postinstall. This is not network-reachable — the host allowlist + https-only + sha256-before-extract block a MITM; it requires write access to the GitHub Release itself, which the file header explicitly designates as the shared trust root and scopes out of provenance. Consistent with the documented trust model. Optional hardening:fs.statSync(extractedPath).sizeagainst a sane bound beforechmod. -
Tag-validation regex diverges from the reusable publish workflow —
release-publish.yml:59,npm-publish.yml:88vs the orgreusable-release-publish.ymlvalidator. The octo-level regex accepts leading zeros (v01.2.3) which the reusable one rejects, and rejects+buildmetadata which the reusable one accepts. Worst case is an orphaned published Release with no npm artifact — fails safe, no arbitrary-ref publish, no escalation. Suggest: align the two regexes, or soften the "lockstep across checkout/gate/set-version/publish" comment so it doesn't overstate parity with the reusable workflow. -
npm-publish gate doesn't verify artifacts are attached —
npm-publish.yml"Verify release exists and is published" queries onlyisDraft,tagName. A repo:write actor (or a run where upload failed after the Release was un-drafted) couldworkflow_dispatchagainst a published-but-empty Release; the gate passes and the wrapper publishes to npm, then 404s at end-user install time. Not a code-exec vector (the tarball contains no binary; repo:write is already trusted) — an availability/robustness gap. Suggest: addassetsto the--jsonquery and assertchecksums.txt+ at least one archive are present before a non-dry-run publish. -
OS/ARCHruntime maps are untested —npm/scripts/install.js:37-38. TheassetNamegrid test feeds already-mapped tokens; theprocess.platform/process.arch→ token translation literals are not exported and have no test, and the only guard (if (!OS || !ARCH)) catches an unset mapping but not a wrong-but-truthy one. A future typo (e.g.win32:"win") would keep CI green yet 404 for everyone on that platform. Correct at head. Suggest: export the maps (or amapPlatform/mapArchhelper) and add a translation test so the whole chain is locked, not just the half after the map. -
.goreleaser.yamluses the deprecated singularformat/format_overrides[].format—.goreleaser.yaml:22-27. Under the pinned~> v2this is a deprecation warning, not a break, and the existing pipeline already uses it. Suggest: rename toformats:before any goreleaser v3 bump, and addgoreleaser checkto CI.
For human attention (security-sensitive PR)
- Trust model is integrity, not provenance — honestly documented in
npm/README.mdand the installer header: the archive and itschecksums.txtshare one trust root (the GitHub Release), so anyone who can write to the Release replaces both consistently. Findings #1 and #3 both bottom out at this same boundary. The mitigations on the roadmap (cosign-signedchecksums.txt, npm--provenanceon the wrapper) are the right direction; worth a human confirming theNPM_TOKENsecret scope and Release-write permissions are tight. - CI is red on two checks that are unrelated to this PR's code:
analyze / CodeQL (go)fails on a repo-config conflict ("CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled"), andcheck-sprintfails because the linked issue has no Sprint board field. Neither is a code defect introduced here; both are repo/process settings a maintainer needs to resolve before merge.
None of the P2 items requires a code change to merge. Nice work on a genuinely careful supply-chain surface.
lml2468
left a comment
There was a problem hiding this comment.
✅ Re-review APPROVED — octo-cli #27 (915dc59)
Rebase onto latest main. Incremental diff adds workflow files already on main (check-sprint, codeql, octo-ci-status, octo-pr-result-notify, octo-pr-review-feed). No changes to npm wrapper code or release-publish.yml.
All fixes from previous 9 rounds remain intact. CI green (build & test, npm test, actionlint, Analyze actions).
Jerry-Xin
left a comment
There was a problem hiding this comment.
Scope check passed: this PR directly adds npm distribution for the octo-cli binary and related release automation.
💬 Non-blocking
- 🟡 Warning: The core postinstall download/extract path is not covered by automated tests.
npm/scripts/install.test.js:3notes this is manual-only, while the highest-risk behavior lives innpm/scripts/install.js:329andnpm/scripts/install.js:363. A mocked HTTPS + fixture archive test would reduce release-time risk. - 🔵 Suggestion: PATH hint commands could quote/escape
ourBinDirmore carefully for shells like fish or unusual install paths with spaces/special characters. Seenpm/scripts/install.js:286andnpm/scripts/install.js:303.
✅ Highlights
- Strong install-time safeguards: strict version validation, redirect host allowlist, SHA-256 verification, download size/deadline caps, and non-regular-file rejection after extraction.
- npm publishing flow has good supply-chain controls: pinned actions, tag namespace checkout, release existence gate,
--access public, prerelease dist-tag handling, and provenance. - Verified locally:
node --test scripts/install.test.js,npm pack --dry-run, andgo test ./...all passed.actionlintwas not available in the environment.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Independent review of the new @mininglamp-oss/octo-cli npm wrapper and the supporting CI/release changes. This PR was reviewed with extra care because it introduces a new supply-chain surface: a postinstall script that downloads, verifies, extracts, and execs a prebuilt binary, plus a workflow that publishes to npm with provenance.
Overall: the code is unusually well-engineered for a postinstall downloader — checksum verified in memory before any disk write, https-only redirect allowlist re-checked on every hop, bounded redirects/size/wall-clock deadline, named-member tar extraction with an lstat symlink guard, SHA-pinned actions, refs/tags/ ref-confusion mitigation, env-var indirection to avoid CWE-94 in workflow inputs, and npm --provenance. The download, extraction, exec, and correctness dimensions surfaced no blocking issues. There is one blocking gap on the publish-trigger path, described below.
Verdict: Changes Requested
A single P1 in the publish pipeline blocks merge. Everything else is non-blocking hardening (P2). The P1 is a narrow, insider-only gap, but on a brand-new npm channel that runs arbitrary code on every consumer via postinstall — and the code's own comment claims a protection it does not implement — so it warrants a fix (or at minimum a corrected comment) before this control is relied upon. I'd also like a human to confirm the threat-model decision here.
P1 (blocking)
npm-publish.yml gate does not replicate the validate_run_id CI-evidence check, contrary to its own comment
.github/workflows/npm-publish.yml, "Verify release exists and is published" step.
The gate performs exactly four checks: the tag matches the v-semver regex, a GitHub Release for the tag exists, gh returned the same tag that was requested, and the release is non-draft. It never verifies that the tagged commit passed CI, nor that the release was produced by release-publish.yml.
returned_tag=$(printf '%s' "$info" | jq -r '.tagName')
if [ "$returned_tag" != "$TAG" ]; then ... fi
is_draft=$(printf '%s' "$info" | jq -r '.isDraft')
if [ "$is_draft" = "true" ]; then ... fi
# <- no validate_run_id / CI-head_sha check, unlike the release-publish chainYet the step's comment asserts: "The release-publish.yml chain (with its validate_run_id evidence check) is the intended publish trigger; this gate enforces that invariant on the downstream workflow too." It does not enforce that invariant.
Why it matters. release-publish.yml requires validate_run_id ("Required: successful CI run ID from the tagged commit as release evidence"), enforced by reusable-release-publish.yml@main. Because CI only runs on push/PR to main, an off-main malicious commit cannot obtain a passing CI run — that is the barrier. But npm-publish.yml is directly workflow_dispatch-able, so the barrier is bypassable:
- A
repo:writecollaborator pushes tagv9.9.9at an arbitrary commit. - They create a published (non-draft) Release for
v9.9.9via the API/UI (release creation needs onlycontents:write, whichrepo:writehas). - They run
gh workflow run npm-publish.yml -f tag=v9.9.9 -f dry_run=false.
The gate passes (regex OK, release exists, non-draft, tag matches), npm-publish checks out refs/tags/v9.9.9, and npm publish --access public --provenance --tag latest ships the attacker-controlled npm/ tree to @latest with a valid Sigstore provenance attestation pointing at the malicious commit. The wrapper's postinstall then runs arbitrary code on every consumer.
The workflow's own comments name repo:write as the in-scope adversary ("any actor with repo:write could directly workflow_dispatch this workflow ... ship that ref to npm @latest with a valid Sigstore provenance attestation"), so this is a gap by the authors' own threat model, not an out-of-scope insider concern. The independent binary sha256 trust path is unaffected; this is specifically about the npm wrapper publish trigger.
Recommendation (any one closes it):
- Replicate the CI-evidence requirement in
npm-publish.yml: resolverefs/tags/<tag>to a commit SHA and require a successful CI run with matchinghead_sha, mirroring the reusable workflow's validate step; or - Have
release-publish.ymlpass the validated run id (or a short-lived signed evidence token) intonpm-publishvia dispatch inputs, and have the gate re-assert it; or - Bind
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}to a protected GitHub Environment with required reviewers and a tag-restricted deployment policy, so a direct unattended dispatch cannot reach the token.
At minimum, correct the comment so it does not claim an invariant the code does not enforce.
P2 (non-blocking — hardening / robustness)
These do not block merge; flagging for follow-up.
Supply chain / CI
- No Environment gate on
NPM_TOKEN(npm-publish.yml, publish job): the token is a plain repo secret with noenvironment:declaration, so any successful dispatch uses it unattended. A protected Environment would add a control plane independent of the in-workflow gate logic (the comments note the empty-TAGfootgun has already regressed once). Closely related to the P1; an Environment gate would also mitigate that path. - Published-before-upload availability window (
release-publish.yml): in thedraft=falsepath the release is un-drafted beforebuild-artifactsuploads the archives +checksums.txt. Anynpm installin that window hits a published release with no assets and fails. Not a security issue (npm-publish is triggered only after upload), but consider cut-as-draft → upload → un-draft, or have the gate assert expected assets are present. - Tag-regex divergence: the validation regex differs across
release-publish.yml,npm-publish.yml, and the reusable workflow (e.g. leading-zero / form differences). Converging on one shared pattern avoids one validator accepting a shape another rejects.
install.js (download / extract)
- Checksum lookup is first-match (
parseChecksumEntry):.find(p => p[1] === asset)silently takes the first line for an asset; duplicate/conflicting entries are never detected. Behind the Release trust root, so not exploitable, but failing closed on duplicates is cheap. - lstat guard rejects symlinks but not hardlinks: a hardlink member resolves to a regular file, so
st.isFile()is true and it proceeds tochmod. Escape is currently blocked only by modern tar's own../-stripping, not by this script. Optionally also rejectst.nlink > 1, or extract to a fresh temp subdir and rename after the type check.
Tests
- The security-critical integration paths are unguarded even though the pure helpers are well tested: the inline
got !== expectedchecksum comparison, the per-hop host enforcement +MAX_REDIRECTSbound indownloadOnce, the 404-vs-5xx-vs-timeout retry classification indownload, and the symlink-rejection branch. Extracting these into small exported helpers (verifyDigest,isRetryable, a path-type check) would let a refactor that breaks the trust boundary fail a test rather than ship green.
run.js / packaging (nits)
run.jsmaps onlyENOENTto a friendly message;EACCES/ENOEXEC(non-executable / wrong-arch binary) fall through to a raw error though "reinstall" is the same remedy.run.jshas no tests.- No
.npmignore; published contents rely entirely on thefileswhitelist (correct today, but a new shipped file must be remembered). Anpm pack --dry-runassertion in CI would lock the manifest.
Items checked that are NOT bugs
To save reviewer time, these "expected" concerns were examined and hold up:
- Workflow inputs are read via
env:vars, not interpolated intorun:blocks — no CWE-94 command injection. - All actions are SHA-pinned (checkout, setup-go, setup-node, goreleaser).
refs/tags/prefixing correctly defeats branch-vs-tag name confusion; the npm-publish handoff dispatches the workflow file from the trusted default branch while packaging the release tag.- Checksum is verified in memory before any disk write / extraction / chmod / exec.
- Redirect allowlist is https-only and re-validated on every hop;
.hostnamedefeats userinfo-injection. - Named-member tar extraction structurally prevents zip-slip from other archive members.
run.jsuses array-argvspawnSync(no shell), propagates signals (128+signum) and exit codes correctly.--provenance/ OIDC config is sound;0.0.0placeholder version is explicitly guarded inmain().
…h gate
yujiawei flagged that the release-existence gate did not actually
enforce the invariant its own comment claimed: a repo:write actor could
still bypass release-publish.yml entirely by pushing a tag at an
arbitrary commit, creating a published Release for it via API/UI, and
dispatching this workflow — every check (regex, exists, tagName,
non-draft) would pass and npm would ship the attacker-controlled
commit with a valid Sigstore provenance attestation.
The reusable workflow that backs release-publish.yml requires an
operator-supplied validate_run_id (a successful CI run whose head_sha
matches the tagged commit). That's the missing invariant; this commit
re-asserts it inline in npm-publish.yml.
New "Verify CI evidence for the tagged commit" step:
1. Resolve refs/tags/$TAG → commit SHA. Handles annotated tags
(deref the tag object to its target commit). Refuses if the
annotated tag points at anything other than a commit.
2. Query `actions/runs?head_sha=<sha>&status=success` and require at
least one workflow_run named "CI" on that exact SHA. The "CI"
name matches reusable-release-publish.yml's `ci_workflow_name`
default; if the workflow is ever renamed, both places must change
in lockstep.
Permissions:
+ actions: read (new, for actions/runs query)
contents: read
id-token: write
The gate documentation also rewritten as "Gate (part 1 of 2)" /
"Gate (part 2 of 2)" so the two-step invariant is explicit and the
old comment no longer over-claims.
Skipped on dry_run (consistent with the release-existence gate above)
so developers can validate plumbing against tags whose Release doesn't
exist yet.
Smoke-tested the query against v0.5.0's commit (b5a4d6a): returns 1
successful CI run, so legitimate tags pass. An attacker-pushed tag at
a commit that never went through CI would return 0 and the gate would
refuse.
Reported by yujiawei (P1) on #27.
Round 10 — closed the missing CI-evidence invariantPushed `9e22c75`. You're right — the gate documentation claimed to enforce an invariant the code didn't enforce. The release-existence + tagName checks were the easy half; the missing half was "the tagged commit must have passed CI." Without it, the bypass you described works exactly as written: repo:write → tag at arbitrary commit → manually create a published Release → dispatch us → all four old checks pass → ship attacker code with valid Sigstore provenance. New step: "Verify CI evidence for the tagged commit"Mirrors reusable-release-publish.yml's `validate_run_id` check, but inline (no extra dispatch input — npm-publish stays self-contained):
If zero CI runs match, refuse with an actionable error pointing the operator at "push through a PR to main, wait for CI, then cut the release via release-publish.yml." Permissions`+ actions: read` for the `actions/runs` query. `contents: read` + `id-token: write` already present. Documentation rewrittenThe two gates are now explicitly labeled "Gate (part 1 of 2) — release existence" and "Gate (part 2 of 2) — CI evidence," and the old comment that claimed the existence check alone enforced the invariant is gone. It now says plainly what each part does and what the two together assert. Smoke-tested against a known-good caseRan the query against v0.5.0's commit (b5a4d6a) → returns 1 successful CI run. An attacker-pushed tag at a commit that never went through CI would return 0 and the gate would refuse. About your P2 listAcknowledged all of them. Not in this PR (each is a sensible follow-up I don't want to bundle into this round):
ReflectionThis is the second time you've found a comment that claimed a stronger invariant than the code enforced (round 7 was the wall-clock deadline). Both times the comment was load-bearing — a future reviewer would have trusted it and not re-derived. Going forward I'll treat any comment that asserts "this enforces X" as a contract worth testing in the same commit (or weakening the comment). Verified
cc @yujiawei |
lml2468
left a comment
There was a problem hiding this comment.
✅ Re-review APPROVED — octo-cli #27 (9e22c75)
feat(npm): add @mininglamp-oss/octo-cli npm wrapper
This commit adds the CI-evidence gate that yujiawei flagged in round 11 — closing the last remaining bypass path in the publish pipeline.
New: "Verify CI evidence for the tagged commit" step:
- Resolves tag → commit SHA (handles annotated tags via deref)
- Queries GitHub API for successful "CI" workflow runs on that SHA
- Refuses to publish if no CI pass exists on the tagged commit
actions: readpermission added to support the API query- Skipped on dry_run (consistent with other gates)
This mirrors release-publish.yml's validate_run_id invariant directly in npm-publish.yml, so the bypass path (push tag → create Release via API → dispatch npm-publish without CI) is now blocked.
Threat model recap — all gates now in place:
- v-prefix enforcement (no branch/tag confusion)
- Resolver before checkout (rejected refs never land)
refs/tags/checkout (explicit tag namespace)- Release existence + non-draft + tagName equality
- Empty TAG bailout
- CI evidence on tagged commit ← new
CI green. All 11 rounds of findings resolved.
Jerry-Xin
left a comment
There was a problem hiding this comment.
Summary: The PR is in scope for octo-cli and the npm wrapper/release workflow changes look ready to merge.
💬 Non-blocking
-
🟡 Warning: .github/workflows/release-publish.yml dispatches
npm-publish.ymlbut does not wait for its result, so the release workflow can finish green even if npm publication later fails. Consider watching the dispatched run if you want one end-to-end release signal. -
🔵 Suggestion: .github/workflows/ci.yml runs the installer unit tests, but CI does not currently run
node --check scripts/run.jsornpm pack --dry-run. Adding those would lock in the manual validation already described in the PR.
✅ Highlights
- The npm installer has solid guardrails: strict platform/version mapping, HTTPS host allowlisting, bounded downloads, checksum verification, and regular-file checks after extraction.
- The publish workflow handles the important release hardening points: v-prefixed semver validation, tag namespace checkout, dist-tag separation for prereleases, npm provenance, and release/CI evidence gates.
- Local verification passed:
npm test,node --checkfor both scripts,npm pack --dry-run,npm publish --dry-run, andgit diff --check.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #27 (octo-cli)
Independent, security-focused review of the @mininglamp-oss/octo-cli npm wrapper and its publish/CI plumbing. This is a supply-chain–sensitive surface: a postinstall script downloads a prebuilt Go binary on every npm install -g, verifies its sha256, extracts, and execs it; two GitHub Actions workflows publish the package to npm with provenance. I re-read the full download → verify → extract path, both publish workflows, the runtime shim, and the packaging manifest at head 9e22c75, and adversarially re-checked each candidate finding against the actual code.
Verdict: APPROVED
The sole blocking issue from the previous round — the npm-publish.yml gate did not replicate the validate_run_id CI-evidence check, letting a repo:write actor publish an arbitrary tagged commit to @latest with valid provenance — is now resolved. The remaining items are non-blocking hardening notes, one of which is security-relevant and worth a human glance.
Previous P1 — resolved ✅
.github/workflows/npm-publish.yml now adds a "Verify CI evidence for the tagged commit" step (gate part 2 of 2). It:
- resolves
refs/tags/<tag>→ commit SHA, dereferencing annotated tags one hop and asserting the target is acommit; - requires at least one successful
CIworkflow run on that exacthead_sha(actions/runs?head_sha=…&status=success, filtered to.name == "CI"); - is correctly skipped on
dry_run, withactions: readadded to the job permissions for the query.
This faithfully mirrors the validate_run_id invariant from reusable-release-publish.yml, and combined with the existing release-existence/non-draft gate it closes the specific bypass described last round (a published Release with no CI evidence at the tagged commit). The comment that previously over-claimed an unenforced invariant has also been rewritten to describe the two-part gate accurately.
The rest of the publish path holds up under scrutiny: env-var indirection (no CWE-94 in run: blocks), SHA-pinned actions, refs/tags/ checkout pinning (defeats branch/tag name confusion), empty-TAG and returned_tag == TAG footgun guards, and --provenance/OIDC config are all sound.
Non-blocking — security-relevant (recommend a human glance)
CI-evidence gate matches on workflow name only (.name == "CI")
.github/workflows/npm-publish.yml — Verify CI evidence step.
A workflow_run's .name comes from the name: key of the workflow YAML on whatever branch triggered it, and GitHub runs on: push workflows from any branch a repo:write actor pushes — not just main. So a repo:write adversary could in principle: push a trojan commit C to a side branch that also contains a throwaway .github/workflows/x.yml with name: CI, on: push, steps that exit 0 (producing a successful run reporting head_sha=C, name="CI"); push tag v9.9.9 → C; create a published Release; dispatch npm-publish. The name-only filter would accept the forged run.
Why this is not a blocker for this PR: the exact same name-only weakness exists in reusable-release-publish.yml@main (it validates a single validate_run_id by workflow_name, equally forgeable via the side-branch trick), which is the agreed-upon trusted reference this step mirrors and is already in use on the accepted release path. This PR does not regress that posture — it brings npm-publish up to parity with it. The independent binary sha256 trust path is unaffected.
Suggested follow-up hardening (applies to both this gate and the reusable reference, best done as a separate change): bind the matched run to head_branch == "main" and path == ".github/workflows/ci.yml" (and ideally event in {push, pull_request}) so a matched "CI" run cannot originate from an attacker-authored side-branch workflow. Even stronger: gate NPM_TOKEN behind a protected GitHub Environment with required reviewers / tag-restricted deployment policy, giving a control plane independent of in-workflow logic.
Non-blocking — hardening / robustness
- Gate enumerates all runs at the SHA rather than pinning a specific operator-attested run id like the reusable reference. Doesn't change exploitability, but is a faithfulness gap vs. the reference.
per_page=100with no pagination on theworkflow_runsquery: a release commit with >100 successful runs could push the real CI run past page 1 and make the gate fail closed (safe direction — refuses to publish — but a confusing failure). Low likelihood for release commits.release-publish.ymlpublished-before-upload window: in thedraft=falsepath the release is un-drafted before artifacts upload.npm-publishis only triggered after upload so this isn't a security issue, but a directnpm installagainst the bare release in that window would fail. Consider cut-as-draft → upload → un-draft..goreleaser.yamluses deprecatedformat:/format_overrides:(singular) underversion: "~> v2". Honored today (warn-only), butformats:(list) is the v2.4+ form; migrating avoids a future v2 cleanup silently breaking the archive namesinstall.jsdepends on.install.jsdefense-in-depth nits (all behind the Release trust root, not remotely reachable):parseChecksumEntrytakes the first matching line (duplicate entries undetected); thelstat().isFile()guard rejects symlinks but a hardlink member resolves to a regular file (currently neutralized only by tar's own../-stripping) — optionally also rejectst.nlink > 1.- Tests: the pure helpers are well covered (host allowlist, semver, checksum parsing, deadline, PATH hints), but the security-critical integration paths (in-memory
got !== expectedcompare, per-hop host enforcement +MAX_REDIRECTS, retry classification, symlink rejection) remain manual-only. Extracting small exported helpers would let a trust-boundary regression fail a test rather than ship green.run.jshas no tests and maps onlyENOENTto a friendly message.
Items checked that are NOT bugs
- Checksum verified in memory before any disk write / extract / chmod / exec; same buffer reused (no verify/extract TOCTOU).
- Redirect allowlist is https-only, re-validated on every hop, uses
URL.hostname(rejectsgithub.com.attacker.com, userinfo@evil.com, port tricks) — verified empirically. - Named-member tar extraction structurally prevents zip-slip from sibling members; symlink member named
octo-clirejected vialstat; GNU tar neutralizes absolute /../hardlink targets — verified with crafted archives. - Size cap (200 MiB, enforced mid-stream) and dual idle + wall-clock deadlines (armed on the in-flight request, bounding the whole retry chain) have no bypass; both archive and
checksums.txtgo throughdownload(). run.jsbin path (__dirname/../bin/octo-cli[.exe]) matches exactly whereinstall.jswrites the binary; array-argvspawnSync(no shell); faithful signal (128+signum) and exit-code propagation.package.jsonfileswhitelist covers every runtime file and excludes the install-time-downloadednpm/bin/and the test;os/cpumaps andassetNameexactly match.goreleaser.yaml'sname_templateand thesha256checksum algorithm.0.0.0placeholder version explicitly guarded inmain().
CI note: build & test, npm test, actionlint, and Analyze (actions) are green; CodeQL is still running. The one red check, check-sprint, is a project-board process gate ("linked issue has no Sprint set — ask a Maintainer to assign it"), not a code defect.
Closes #31
What
Distributes
octo-clivia npm so Node-based agent runtimes (OpenClaw, etc.) cannpm install -g @mininglamp-oss/octo-cli. The package is a thin wrapper around the prebuilt Go binary.npm/—package.json(bin:octo-cli, version is a0.0.0placeholder filled at publish time),install.js(postinstall: download the matching release archive → verify sha256 → extract),run.js(shim that execs the binary),READMEinstall.jsPATH hint — on a global install, if npm's bin dir isn't onPATH, it prints a shell-specificexport PATH=…hint (zsh/bash/fish/Windows). Warn only; never edits config..github/workflows/npm-publish.yml— publishes on GitHub Releasepublished.gitignore— ignorenpm/bin/(the downloaded binary)README— npm install sectionAligned with octo-adapters' publish flow
After comparing with
publish-octo.yml/create-openclaw-octo:--access public(scoped packages default to private)NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}viasetup-node-, e.g.v0.6.0-rc.1) publishes to@next; stable to@latest— so an rc never clobbers@latestcheckout/setup-node,permissions: {},concurrencyguardworkflow_dispatchwith a--dry-runpath for manual testingDeliberately NOT adopted: the dev-publish-on-every-merge workflow. octo-cli's wrapper downloads a binary from a matching release, so its npm version must track a real release tag — a per-merge
-dev.<sha>version would have no matching release artifact and produce an uninstallable package.Dependencies / merge order
octo→octo-clirename (refactor!: rename binary and CLI command from octo to octo-cli #26, merged): the wrapper downloadsocto-cli_<version>_<os>_<arch>archives.octo-cli_prefix — i.e. v0.6.0+. Merging this PR is safe before then; it just won't publish until such a release is cut andNPM_TOKENis set (already configured on this repo).Testing
node --checkon install.js / run.js: cleannpm pack(with a real version) producesmininglamp-oss-octo-cli-<v>.tgz--access public+ dist-tag logic present