Install portability - MCP loopback+token, shasum fallback, atomic vendor
TASK-IMP-137: Install portability - MCP loopback+token, shasum fallback, atomic vendor
Summary
Five verified portability/exposure defects in the install channel: the MCP server's --http mode binds every interface with zero auth while serving repo-rewriting, shell-running tools; bootstrap.sh hardcodes GNU sha256sum, breaking checksum verification on stock macOS; the payload's engines field admits Node 18 while shipping a subpackage that demands 24; the README documents a GitHub Action channel at a path install never creates; and the vendor step's rm -rf + cp -R leaves a reader-visible window where .cyberos/ is missing or partial. This task closes all five: loopback-by-default + optional bearer token, shasum fallback, one engine floor, a real ci/ vendor + docs truth, and stage-then-swap vendoring.
Problem
All verified first-hand 2026-07-23:
- LAN exposure by default (audit C4).
cyberos-mcp.mjs:219calls.listen(port)with no host, binding0.0.0.0.POST /mcpaccepts unauthenticated JSON-RPC and the tool set includestask_install(rewrites the repo) andtask_gates(runsrun-gates.sh, whichevals configured commands). Anyone on the local network can drive both. The code comment defers auth to a production reverse proxy, but the default posture is the exposure. - Checksum verification fails on macOS.
bootstrap.sh:23pipes tosha256sum -c; stock macOS shipsshasum, not GNU coreutils. The security step of the curl|bash channel errors out on the platform the project is developed on - inviting users to bypass it. - Engines contradiction. The generated payload admits
node >=18(build.sh:358); the shippedmcp/package.jsondemands>=24 <25; every.nvmrcpins 24.18.0. A Node-18 user passes npm's engine check and then runs components nobody has ever tested on 18. - Phantom CI channel. README:158 tells operators to point a workflow at the composite action "after
install.shhas committed.cyberos/to the repo" - but install.sh never copiesci/, so the documented path does not exist in any installed repo. - Partial-vendor window. Between
rm -rf "$CY/cuo"and the completedcp -R, an agent reading.cyberos/(which agents do constantly - it is their entry point) sees a half-machine. The TASK-IMP-103 lock serializes installers only.
Proposed Solution
MCP: --http binds 127.0.0.1 by default; a new --host <addr> flag opts into wider binding and its help text names the exposure; when the env var CYBEROS_MCP_TOKEN is set non-empty, every POST /mcp must carry Authorization: Bearer <token> (401 otherwise), while GET /healthz stays open for probes; binding non-loopback WITHOUT a token prints a loud warning. bootstrap.sh: detect sha256sum else fall back to shasum -a 256; fail with a clear message only when neither exists. Engines: the generated payload package.json pins "node": ">=24 <25", matching the .nvmrc floor and the mcp subpackage. CI channel: install.sh vendors ci/ into .cyberos/ci/ (ownership-marked like the other vendored trees), and README:158's instructions are corrected to reference the installed path with a working uses: example. Atomic vendor: each vendored subtree is staged as "$CY/<name>.tmp.<nonce>" then swapped into place (rm -rf old + mv staged) so the reader-visible gap per subtree shrinks from the full copy duration to a rename; the existing install lock continues to serialize installers. A new suite covers all five behaviors.
Alternatives Considered
- Full TLS/OAuth on the MCP HTTP mode. Rejected for this task: the connector is a local-first developer channel; loopback default + optional bearer token closes the unauthenticated-LAN hole without dragging in cert management. Production deployments keep the documented reverse-proxy story, now as defense-in-depth rather than the only defense.
- Deny
--httpentirely unless a token is set. Rejected: loopback-only unauthenticated use (the overwhelmingly common local case) is not the vulnerability; the LAN binding is. Requiring tokens for localhost adds friction with no threat-model gain. - Relax mcp/package.json to
>=18instead of raising the payload floor. Rejected: no CI or developer machine tests Node 18 (every .nvmrc is 24.x); an engines field is a compatibility promise, and promising untested compatibility is the stub-workflow lie in a different file. Raising the floor is honest; users on older Node get a clear npm engines error instead of a runtime surprise. - Docs-only fix for the CI channel (point at the npm package path). Rejected: a composite action consumed from the repo's own committed tree (
.cyberos/ci/github-action) is the standard, zero-download pattern and matches what the README already promised; making the promise true is smaller than re-teaching it. - Whole-
.cyberos/staged swap for atomicity. Rejected:.cyberos/contains machine-local state that must survive installs (config.yaml,gates.envbackups,memory/store/); swapping the whole dir risks the config-wipe class. Per-subtree swaps touch exactly the machine-owned trees.
Success Metrics
- Primary: by the next CyberOS release -
--httpwith no flags refuses remote connections (bind 127.0.0.1, verified by connect attempt from a non-loopback source address in the test harness); withCYBEROS_MCP_TOKENset, tokenless POSTs get 401;bootstrap.shcompletes checksum verification on a machine with onlyshasum; the payload package.json engines equals>=24 <25;.cyberos/ci/github-action/action.ymlexists after a scratch install; and no reader observes a missing vendored subtree during an install loop (sampled reader in the test). Baselines today: all five fail. - Guardrail: stdio MCP mode (the default channel) is byte-for-byte unaffected; existing install suites (
test_install_hygiene.sh,test_install_lock.sh,test_e2e_skeleton.sh) stay green.
Scope
In scope: the five fixes above, their README/mcp-README corrections, the new test suite, CHANGELOG.
Out of scope / Non-Goals
- TLS termination, OAuth flows, or multi-user token management for the MCP connector (reverse-proxy guidance remains the production story).
- The fail-closed gates behavior and gates.env header - TASK-CUO-302.
- Uninstall-side preservation of
.cyberos/ci/(uninstall completeness is TASK-IMP-126's domain; the new tree is ownership-marked so existing uninstall logic handles it). - The G16 reinstall-idempotency benchmark definition - TASK-IMP-140 (its checker exercises the atomic-vendor behavior this task ships; soft forward reference, no cycle).
Dependencies
None blocking. TASK-IMP-076 (done) shipped the MCP server and its --http mode; TASK-IMP-103 (done) shipped the installer lock whose reader gap this task closes the other half of. TASK-IMP-140's G16 gate builds on the atomic-vendor guarantee - forward reference only.
AI Authorship Disclosure
- Tools used: Claude (Fable 5) running the CyberOS
task-authorskill in Cursor, as the task-authoring wave of the 2026-07-23 hardening plan. - Scope: the bind call, the missing auth check, the
sha256sumhardcode, the three engines values, the phantomci/path, and the rm/cp window were each verified by direct source read at HEAD; the payload files list ("ci"present in npmfiles, absent from install.sh's vendor list) was cross-checked both sides. - Human review: the hardening plan was operator-approved 2026-07-23; the engines-up and vendor-ci decisions are recorded in
source_decisionsfor the review acceptance gate.
1. Description (normative)
- 1.1
cyberos-mcp.mjs --httpMUST bind127.0.0.1when no host is specified; a new--host <addr>argument MUST be the only way to bind any other address, and starting non-loopback without a token MUST print a warning naming the exposure. - 1.2 When the environment variable
CYBEROS_MCP_TOKENis set non-empty, everyPOST /mcprequest MUST be rejected 401 (JSON-RPC error body) unless it carriesAuthorization: Bearer <token>with an exact token match;GET /healthzMUST remain unauthenticated. Token comparison MUST be constant-time-safe in intent (no early-exit substring tricks) though the threat model is LAN, not timing labs. - 1.3
bootstrap.shMUST verify the payload checksum viasha256sum -cwhen available, elseshasum -a 256 -c, and MUST abort with a message naming both tools when neither exists. The fallback MUST verify, not skip - absence of GNU coreutils is not permission to trust the network. - 1.4 The payload package.json generated by
build.shMUST declare"engines": { "node": ">=24 <25" }, matching the repo's.nvmrcfloor and the shipped mcp subpackage. - 1.5
install.shMUST vendor the payload'sci/tree into.cyberos/ci/(ownership-marked consistently with the other vendored trees), andtools/install/README.md's GitHub Action section MUST reference the installed path with a validuses: ./.cyberos/ci/github-actionexample. - 1.6 Each vendored subtree replacement in
install.shMUST be staged (cp -Rinto"$CY/<name>.tmp.<nonce>"first) and swapped into place so the reader-visible absence window per subtree is bounded by directory rename/move operations, not by copy duration. Stray*.tmp.*staging dirs from killed installs MUST be cleaned at the next install start. - 1.7
CHANGELOG.mdMUST record all five changes, marking the engines raise and the loopback default as breaking for consumers who relied on Node 18 or LAN binding.
2. Acceptance criteria
- [ ] AC 1 (traces_to: #1.1) -
--httpwith no host accepts loopback connections and refuses non-loopback (asserted via the OS-reported bound address AND a failed connect from a secondary address where the harness supports it);--host 0.0.0.0binds wide and tokenless startup prints the exposure warning - test:tools/install/tests/test_install_portability.sh::t01_loopback_default - [ ] AC 2 (traces_to: #1.2) - with
CYBEROS_MCP_TOKEN=secret: tokenless POST /mcp gets 401, wrong token gets 401, correct Bearer succeeds, GET /healthz succeeds tokenless - test:tools/install/tests/test_install_portability.sh::t02_bearer_token_enforced - [ ] AC 3 (traces_to: #1.3) - on a PATH without
sha256sumbut withshasum, bootstrap's verification step succeeds against a good archive and FAILS against a corrupted one (the fallback verifies); with neither tool, it aborts naming both - test:tools/install/tests/test_install_portability.sh::t03_shasum_fallback_verifies - [ ] AC 4 (traces_to: #1.4) - a scratch payload's package.json engines equals
>=24 <25exactly - test:tools/install/tests/test_install_portability.sh::t04_engines_unified - [ ] AC 5 (traces_to: #1.5) - after a scratch install,
.cyberos/ci/github-action/action.ymlexists, and README's section contains theuses: ./.cyberos/ci/github-actionform with no remaining claim that dist paths work post-install - test:tools/install/tests/test_install_portability.sh::t05_ci_channel_real - [ ] AC 6 (traces_to: #1.6) - a reader loop polling
.cyberos/cuo/ship-tasks.mdexistence during 20 reinstall iterations observes zero absences; a simulated kill between stage and swap leaves the OLD tree intact and the next install cleans the stray staging dir - test:tools/install/tests/test_install_portability.sh::t06_atomic_swap_no_reader_gap - [ ] AC 7 (traces_to: #1.7) - CHANGELOG's top entry mentions all five changes and the word "breaking" for engines + binding - test:
tools/install/tests/test_install_portability.sh::t07_changelog_five_changes
3. Edge cases
- Agent UIs that connected to the LAN-bound port yesterday: after upgrade they must either run on the same host (loopback) or pass
--hostdeliberately - the CHANGELOG breaking note plus the startup warning carry the migration; silence would re-create the exposure by habit. - Token set but empty (
CYBEROS_MCP_TOKEN=""): treated as unset (no auth), because an empty bearer token is unusable as a credential; the mcp README documents this explicitly. /healthzinformation disclosure: the health body includes server name/version only (as today); the token gate deliberately excludes it so probes work, and no tool metadata is served there.- macOS with Homebrew coreutils installed:
sha256sumexists and wins - the fallback ordering preserves today's behavior wherever it already worked. - Node 18 consumer pinned by their own CI: npm refuses install with a clear engines error - the intended outcome; the CHANGELOG names the floor and the .nvmrc value to adopt.
- Kill mid-swap (between rm of old and mv of staged): the window is the pathological remnant - the test's kill simulation targets stage-complete/pre-swap (old tree intact); a kill inside the rm+mv pair itself is bounded by two syscalls and the next install's staging cleanup + full re-vendor restores the machine; the install lock keeps a second installer out of the gap either way.
- Security-class: this task is itself a security fix (default exposure removal + verified checksums). The bearer token lives in an env var, never in repo files; tests use throwaway values; no token is logged (the warning names the CONDITION, not the secret).
Audit
§1 — Verdict summary
Seven §1 clauses, seven ACs, seven edge cases including a security-class row (this task is itself a security fix). The audit pressure fell on verify-don't-skip semantics in the checksum fallback, the atomicity claim's honest bounds (what a kill mid-swap can still do), and making the two breaking changes (engines, binding) carry explicit migration paths.
§2 — Findings (all resolved)
ISS-001 — the checksum fallback could have been implemented as a skip
"Fall back when sha256sum is absent" is satisfiable by skipping verification — the worst possible reading for the one security step in a curl|bash channel. Resolved: clause 1.3 states "the fallback MUST verify, not skip"; AC 3 asserts the fallback FAILS on a corrupted archive (the negative half that distinguishes verifying from skipping).
ISS-002 — the atomicity claim was absolute in the first draft
"Atomic vendor" overclaims: rm -rf old && mv staged is two operations, not one atomic rename, and a kill between them still leaves a gap. Resolved: clause 1.6 bounds the claim honestly (window bounded by rename/move operations, not copy duration; stray staging cleanup at next install), and the edge case names the pathological kill-inside-the-pair remnant with its recovery story. AC 6 tests both the reader loop and the kill simulation at the stage-complete point.
ISS-003 — empty-token semantics were undefined
CYBEROS_MCP_TOKEN="" could be read as "auth on, all requests fail" (bricking loopback use via a stray export) or "auth off". Resolved: edge case pins empty-as-unset with the rationale (an empty bearer is unusable as a credential) and requires the mcp README to document it.
ISS-004 — token leakage via logs was unaddressed
A warning line that echoes the configured token (or a 401 body that reflects the attempted one) would put the secret in transcripts. Resolved: the security-class edge case requires that no token value is logged — the warning names the condition, never the secret.
ISS-005 — binding assertion needed to be observable, not configuration-echo
Asserting "we passed 127.0.0.1 to listen()" tests the argument, not the exposure. Resolved: AC 1 asserts via the OS-reported bound address AND a refused non-loopback connect where the harness supports a secondary address — behavior, not configuration.
ISS-006 — engines raise lacked a consumer migration statement
Raising the floor from >=18 to >=24 <25 is breaking for any consumer on older Node; the first draft noted "breaking" only generically. Resolved: edge case states the intended outcome (clear npm engines error) and requires the CHANGELOG to name the floor and the .nvmrc value; AC 7 asserts the breaking language.
ISS-007 — the vendored ci/ tree's uninstall story was unstated
Adding a new vendored tree without saying who removes it invites an uninstall leftover (the TASK-IMP-121/126 class). Resolved: Non-Goals states the tree is ownership-marked consistently so existing uninstall logic handles it, with TASK-IMP-126 named as the completeness owner.
§3 — TRACE-006 semantic sufficiency (per clause)
| Clause | Verb demand | Cited test asserts | Verdict |
|---|---|---|---|
| 1.1 MUST bind loopback default; --host only opt-out; warn tokenless-wide | bound address + refused remote connect + wide-bind warning | AC 1: asserts all three | sufficient after revision (ISS-005) |
| 1.2 MUST 401 without exact Bearer; healthz open | tokenless 401, wrong 401, correct 200, healthz 200 | AC 2: asserts all four | sufficient |
| 1.3 MUST verify via fallback; abort when neither tool | fallback success AND corrupted-archive failure + neither-tool abort | AC 3: asserts all three including the verifying-negative | sufficient after revision (ISS-001) |
| 1.4 MUST declare >=24 <25 | exact engines string in scratch payload | AC 4: asserts exact match | sufficient |
| 1.5 MUST vendor ci/ + correct README | installed action.yml + uses: example + no stale claim | AC 5: asserts all three | sufficient |
| 1.6 MUST stage-then-swap; clean strays | zero reader absences over 20 reinstalls + kill leaves old tree + stray cleanup | AC 6: asserts all three | sufficient after revision (ISS-002) |
| 1.7 MUST record five changes, two breaking | five mentions + breaking language | AC 7: asserts both | sufficient |
§4 — Resolution
Seven findings — two security-honesty, five material — all resolved in the audited revision. Score = 10/10.
Status transition draft -> ready_to_implement is authorised by this verdict per STATUS-REFERENCE.md §1.1. The two human-acceptance gates in /ship-tasks are unchanged — this audit clears the spec-correctness gate only.
End of TASK-IMP-137 audit.