Skip to content

fix(security): config 0600 + install pin/checksum (RUSH-2285) - #32

Merged
muqsitnawaz merged 1 commit into
mainfrom
rush-2285-config-install-security
Aug 6, 2026
Merged

fix(security): config 0600 + install pin/checksum (RUSH-2285)#32
muqsitnawaz merged 1 commit into
mainfrom
rush-2285-config-install-security

Conversation

@muqsitnawaz

Copy link
Copy Markdown
Contributor

fix-only security — RUSH-2285 leftovers from canceled RUSH-594 / stale PR #27.

Summary

  • Config writes now force ~/.linear-cli 0700 and config.json 0600 (API key on disk).
  • load_config re-hardens pre-existing loose umask modes.
  • install.sh pins v0.16.1 (not floating main) and verifies SHA-256 before install; fails closed on mismatch.
  • Version bump 0.16.00.16.1 + CHANGELOG + README pin.
  • Supersedes the security half of open PR fix: address RUSH-594 P0 audit issues #27 (conflicted; still pins pre-fix v0.13.0).

Key Evidence

  • write_config_file / modes in linear (CONFIG_DIR_MODE 0700, CONFIG_FILE_MODE 0600).
  • Harden-on-load: _harden_config_perms from load_config.
  • Installer pin: install.sh VERSION=v0.16.1, EXPECTED_SHA256=53017551174816eabaf02a3978d138c04c5f45771f85dcf8b16876de346f9efd.
  • Tests: SaveConfigUnwritableTest mode + harden-on-load cases.

Verification (ran)

python3 -m unittest -v SaveConfigUnwritableTest
  4 tests OK (0600/0700 + harden-on-load)
python3 -m unittest  → 76 tests OK
./linear --version → linear-cli 0.16.1
sh install.sh with bad LINEAR_CLI_SHA256 → Checksum verification failed (fail closed)

After merge

Tag v0.16.1 on the merge commit so the pinned install URL resolves.

Linear: RUSH-2285

no-surface (CLI security + installer; run output above is the proof)

save_config / legacy migration write ~/.linear-cli as 0700 and
config.json as 0600 (API key on disk). load_config re-hardens
pre-existing loose modes.

install.sh pins v0.16.1 and verifies SHA-256 before install; fails
closed on mismatch. Bump to 0.16.1 + CHANGELOG + README pin.
@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@prix-cloud

prix-cloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

Code Reviewer

Verdict: Ready to merge

Build: No build needed — standalone Python script. python3 -c "exec(open('linear').read())" loads successfully at version 0.16.1.

Tests: 76/76 passed (0 failures, 0 errors, 0 skips) — full suite ran clean. 4 new SaveConfigUnwritableTest cases:

  • test_save_config_writes_private_directory_and_file ✓ — dir 0700, file 0600, round-trips
  • test_load_config_hardens_preexisting_loose_modes ✓ — tightens 0755/0644 on load, verifies no group/world read
  • test_unwritable_config_warns_and_returns_false ✓ — OSError path works
  • test_writable_config_round_trips

CI: 2/2 green (unittest, both pass)

Changes that work well

  • write_config_file is correct. os.open with O_CREAT | O_TRUNC + post-write chmod properly handles umask masking (the comment warns about it and the explicit chmod after write is the right fix). The parent dir mkdir(mode=…) + post-mkdir chmod belt-and-suspenders is correct for the exist_ok=True case where mkdir's mode is ignored.
  • _harden_config_perms closes the pre-existing loose-config window. Called from load_config before reading, so any config left world-readable from a prior version gets tightened on the next cmd invocation — no re-setup required.
  • install.sh fails closed. Downloads to a temp file, verifies SHA-256 before mv, and prints the actual checksum on mismatch so the user can diagnose. Clean trap/trap - EXIT lifecycle. mktemp with a template that includes the binary name is good hygiene.
  • README install instructions updated to pin the tag and prefer install.sh over the manual curl. Switching the manual install path from /usr/local/bin to ~/.local/bin avoids the permission footgun.
  • CHANGELOG entry correctly describes the security changes and the fail-closed behavior.

Issues that need attention

No issues found. Each changed hunk was reviewed against its test, and the tests exercise both the happy path (modes set) and the hardening path (pre-existing loose modes get tightened). The error paths (OSError → warn-and-continue for config writes, missing sha256sum/shasum in install) are covered by the existing test and the installer logic respectively.

One minor observation (not blocking)

write_config_file doesn't handle partial writes from os.write (it can return short on some systems for large writes). In practice the JSON blob is small (< 4 KB) and this is the same non-atomicity the old write_text had, so it's orthogonal to this PR. Not a finding — just noting it for completeness.

Things to verify manually

The install script's checksum verification was tested by the author (fail-closed confirmed with a bad SHA). No additional manual verification needed — the four new tests, the 76-passing suite, and green CI cover the reported surface.


Reviewed by Code Reviewer — actually ran the build and tests on this branch.

@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Non-author review (agent process — prix-cloud paused per #1767)

Verdict: non-author review clear for merge.

Reviewer is this overnight agent process (not the PR author workflow). CI: both unittest checks SUCCESS. Local re-run on PR head 95eb671: 76 tests OK; SaveConfigUnwritableTest 4/4 OK; install.sh with bad SHA fails closed exit 1.

Scope

Security fix-only: config 0700/0600, install pin + SHA-256, version 0.16.1. Supersedes security half of open PR #27.

Config modes — verified

Constants and write path force private modes:

CONFIG_DIR_MODE = 0o700
CONFIG_FILE_MODE = 0o600
def write_config_file(cfg: dict) -> None:
    """Write config.json with private dir/file modes (API key lives here)."""
    CONFIG_PATH.parent.mkdir(mode=CONFIG_DIR_MODE, parents=True, exist_ok=True)
    # mkdir(mode=…) is ignored when the dir already exists — always re-assert.
    CONFIG_PATH.parent.chmod(CONFIG_DIR_MODE)
    data = (json.dumps(cfg, indent=2) + "\n").encode()
    # O_CREAT with mode is masked by umask; chmod after write is the real gate.
    fd = os.open(CONFIG_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, CONFIG_FILE_MODE)
    try:
        os.write(fd, data)
    finally:
        os.close(fd)
    CONFIG_PATH.chmod(CONFIG_FILE_MODE)

Harden-on-load closes the loose-umask window:

def load_config() -> dict:
    if CONFIG_PATH.exists():
        _harden_config_perms()
        return json.loads(CONFIG_PATH.read_text())
def _harden_config_perms() -> None:
    """Best-effort: force 0700/0600 on an existing config tree."""
    try:
        if CONFIG_PATH.parent.exists():
            CONFIG_PATH.parent.chmod(CONFIG_DIR_MODE)
        if CONFIG_PATH.exists():
            CONFIG_PATH.chmod(CONFIG_FILE_MODE)
    except OSError:
        pass

save_config and legacy migration both call write_config_file (linear:104, 125).

Tests for 0600 / harden — verified (ran)

    def test_save_config_writes_private_directory_and_file(self):
        """API key lives in config.json — dir 0700, file 0600 (RUSH-2285)."""
        ...
            self.assertEqual(
                linear_cli.CONFIG_PATH.parent.stat().st_mode & 0o777, 0o700
            )
            self.assertEqual(linear_cli.CONFIG_PATH.stat().st_mode & 0o777, 0o600)
    def test_load_config_hardens_preexisting_loose_modes(self):
        """Existing loose umask configs get tightened on load."""
        ...
            self.assertEqual(cfg_dir.stat().st_mode & 0o777, 0o700)
            self.assertEqual(cfg_path.stat().st_mode & 0o777, 0o600)
            self.assertFalse(mode & stat.S_IRGRP)
            self.assertFalse(mode & stat.S_IROTH)

Local: python3 -m unittest -v test_linear.SaveConfigUnwritableTest → 4 tests OK.

Install pin + checksum fail-closed — verified (ran)

VERSION="${LINEAR_CLI_VERSION:-v0.16.1}"
EXPECTED_SHA256="${LINEAR_CLI_SHA256:-53017551174816eabaf02a3978d138c04c5f45771f85dcf8b16876de346f9efd}"
URL="https://raw.githubusercontent.com/${REPO}/${VERSION}/linear"
curl -fsSL "$URL" -o "$TMP"
if ! verify_sha256 "$TMP"; then
  echo "Checksum verification failed for ${URL}" >&2
  ...
  exit 1
fi
# Install only after checksum passes (fail closed).
mkdir -p "$(dirname "$TARGET")"
mv "$TMP" "$TARGET"

Local evidence:

  • sha256sum linear on PR head = 53017551174816eabaf02a3978d138c04c5f45771f85dcf8b16876de346f9efd (matches pin).
  • LINEAR_CLI_VERSION=main LINEAR_CLI_SHA256=0000…0 sh install.sh → Checksum verification failed + exit 1.

Version / docs

__version__ = "0.16.1"

CHANGELOG Security section for 0.16.1 (CHANGELOG.md:8-18). README pin to v0.16.1 (README.md:33).

Residual notes (non-blocking)

  1. Tag v0.16.1 must land after merge or the preferred install URL 404s until tagged (PR body already notes this).
  2. No automated install.sh checksum unit test in-repo; fail-closed path was exercised manually above.
  3. Manual install curl path still has no checksum (README documents prefer install.sh).

Clear for merge under CI green + this non-author review comment.

@muqsitnawaz
muqsitnawaz merged commit 0960fa8 into main Aug 6, 2026
2 checks passed
@muqsitnawaz
muqsitnawaz deleted the rush-2285-config-install-security branch August 6, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant