diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c82ca..76ee113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.1] - 2026-08-06 + +### Security + +- **Config file is private.** `save_config` / first-write / legacy migration now + create `~/.linear-cli` as `0700` and `config.json` as `0600` (the API key lives + there). A pre-existing loose-mode config is re-chmod'd on every `load_config`. +- **`install.sh` fails closed on a bad download.** Pins a release tag (default + `v0.16.1`, not floating `main`) and verifies SHA-256 before moving the binary + into place. Override with `LINEAR_CLI_VERSION` / `LINEAR_CLI_SHA256` only when + deliberately installing a different revision. + ## [0.16.0] - 2026-08-05 ### Changed diff --git a/README.md b/README.md index 3f61d76..eebac3e 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,17 @@ Use the same shell contract whether you're typing or a subagent is: query the qu ## Install +Preferred (pinned tag + SHA-256 verify via `install.sh`): + ```bash -curl -sSL https://raw.githubusercontent.com/phnx-labs/linear-cli/main/install.sh | bash +curl -sSL https://raw.githubusercontent.com/phnx-labs/linear-cli/v0.16.1/install.sh | sh ``` -Or manually: +Manual pin (same tag; no checksum — prefer `install.sh` above): ```bash -curl -o /usr/local/bin/linear https://raw.githubusercontent.com/phnx-labs/linear-cli/main/linear -chmod +x /usr/local/bin/linear +curl -o ~/.local/bin/linear https://raw.githubusercontent.com/phnx-labs/linear-cli/v0.16.1/linear +chmod +x ~/.local/bin/linear ``` ## Setup diff --git a/install.sh b/install.sh index d450e0d..ddff4ff 100755 --- a/install.sh +++ b/install.sh @@ -4,8 +4,12 @@ set -eu REPO="phnx-labs/linear-cli" -BRANCH="${LINEAR_CLI_BRANCH:-main}" -URL="https://raw.githubusercontent.com/${REPO}/${BRANCH}/linear" +# Pin a release tag (not floating main). Override with LINEAR_CLI_VERSION / +# LINEAR_CLI_SHA256 only when you deliberately install a different revision. +VERSION="${LINEAR_CLI_VERSION:-v0.16.1}" +# SHA-256 of the `linear` file at VERSION. Recomputed whenever VERSION bumps. +EXPECTED_SHA256="${LINEAR_CLI_SHA256:-53017551174816eabaf02a3978d138c04c5f45771f85dcf8b16876de346f9efd}" +URL="https://raw.githubusercontent.com/${REPO}/${VERSION}/linear" pick_install_dir() { if [ -w "/usr/local/bin" ]; then @@ -23,15 +27,44 @@ if ! command -v python3 >/dev/null 2>&1; then exit 1 fi +verify_sha256() { + file="$1" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$EXPECTED_SHA256" "$file" | sha256sum -c - >/dev/null + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$file" | awk '{print $1}')" + [ "$actual" = "$EXPECTED_SHA256" ] + else + echo "sha256sum or shasum is required to verify the download." >&2 + exit 1 + fi +} + INSTALL_DIR="$(pick_install_dir)" TARGET="${INSTALL_DIR}/linear" +TMP="$(mktemp "${TMPDIR:-/tmp}/linear-cli.XXXXXX")" +trap 'rm -f "$TMP"' EXIT -echo "Downloading linear-cli to ${TARGET}" -curl -fsSL "$URL" -o "$TARGET" +echo "Downloading linear-cli ${VERSION} to ${TARGET}" +curl -fsSL "$URL" -o "$TMP" +if ! verify_sha256 "$TMP"; then + echo "Checksum verification failed for ${URL}" >&2 + echo "Expected SHA-256: ${EXPECTED_SHA256}" >&2 + if command -v sha256sum >/dev/null 2>&1; then + echo "Actual: $(sha256sum "$TMP" | awk '{print $1}')" >&2 + elif command -v shasum >/dev/null 2>&1; then + echo "Actual: $(shasum -a 256 "$TMP" | awk '{print $1}')" >&2 + fi + exit 1 +fi +# Install only after checksum passes (fail closed). +mkdir -p "$(dirname "$TARGET")" +mv "$TMP" "$TARGET" +trap - EXIT chmod +x "$TARGET" echo "" -echo "Installed: $TARGET" +echo "Installed: $TARGET (${VERSION})" if ! echo ":$PATH:" | grep -q ":${INSTALL_DIR}:"; then echo "" echo "Note: ${INSTALL_DIR} is not on your PATH. Add this to your shell rc:" diff --git a/linear b/linear index 32b0ef0..8b4e917 100755 --- a/linear +++ b/linear @@ -39,7 +39,7 @@ from pathlib import Path from urllib.request import Request, urlopen from urllib.error import URLError -__version__ = "0.16.0" +__version__ = "0.16.1" # Sentinel for "flag not supplied" — distinct from None, which means an explicit # clear (e.g. `--project none`). Lets update pre-resolve a field once and pass @@ -50,6 +50,10 @@ CONFIG_PATH = Path.home() / ".linear-cli" / "config.json" LEGACY_CONFIG_PATH = Path.home() / ".agents" / "linear.json" API_URL = "https://api.linear.app/graphql" +# Config holds the Linear API key — keep it private on multi-user boxes. +CONFIG_DIR_MODE = 0o700 +CONFIG_FILE_MODE = 0o600 + # How long a cached agent roster stays fresh before an auto-refresh. Agent apps # (Claude, Codex, Kimi, …) are installed/removed rarely, so a periodic refresh # keeps `--delegate ` resolving without a per-call lookup. @@ -60,13 +64,44 @@ AGENTS_TTL_SECONDS = 6 * 3600 # Config # --------------------------------------------------------------------------- +def _harden_config_perms() -> None: + """Best-effort: force 0700/0600 on an existing config tree. + + Upgrades written before this release (or created with a loose umask) stay + world-readable until the next `save_config`. Touching modes on every load + closes that window without requiring a re-setup. + """ + 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 + + +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) + + def load_config() -> dict: if CONFIG_PATH.exists(): + _harden_config_perms() return json.loads(CONFIG_PATH.read_text()) if LEGACY_CONFIG_PATH.exists(): cfg = json.loads(LEGACY_CONFIG_PATH.read_text()) - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n") + write_config_file(cfg) print(f"Migrated config: {LEGACY_CONFIG_PATH} -> {CONFIG_PATH}", file=sys.stderr) return cfg return {} @@ -80,13 +115,14 @@ def save_config(cfg: dict) -> bool: command that triggered the refresh — the in-memory values are still good for this run. `setup` checks the return value, because there persistence IS the operation. + + Always writes dir `0700` and file `0600` so the API key is not world-readable. """ # Volatile cfg (e.g. --team override) opts out of persistence. if cfg.get("__volatile__"): return True try: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n") + write_config_file(cfg) return True except OSError as e: print(f"Warning: could not write {CONFIG_PATH}: {e}", file=sys.stderr) diff --git a/test_linear.py b/test_linear.py index 96dc397..875e1de 100644 --- a/test_linear.py +++ b/test_linear.py @@ -813,6 +813,40 @@ def test_writable_config_round_trips(self): self.assertTrue(linear_cli.save_config({"agent": "claude"})) self.assertEqual(linear_cli.load_config()["agent"], "claude") + def test_save_config_writes_private_directory_and_file(self): + """API key lives in config.json — dir 0700, file 0600 (RUSH-2285).""" + import pathlib + with tempfile.TemporaryDirectory() as d: + self._with_config_path(pathlib.Path(d) / ".linear-cli" / "config.json") + self.assertTrue( + linear_cli.save_config({"apiKey": "lin_api_secret", "teamId": "team"}) + ) + self.assertEqual( + linear_cli.CONFIG_PATH.parent.stat().st_mode & 0o777, 0o700 + ) + self.assertEqual(linear_cli.CONFIG_PATH.stat().st_mode & 0o777, 0o600) + self.assertEqual(linear_cli.load_config()["apiKey"], "lin_api_secret") + + def test_load_config_hardens_preexisting_loose_modes(self): + """Existing loose umask configs get tightened on load.""" + import pathlib + import stat + with tempfile.TemporaryDirectory() as d: + cfg_dir = pathlib.Path(d) / ".linear-cli" + cfg_dir.mkdir(mode=0o755) + cfg_path = cfg_dir / "config.json" + cfg_path.write_text('{"apiKey": "lin_api_loose"}\n') + cfg_path.chmod(0o644) + self._with_config_path(cfg_path) + loaded = linear_cli.load_config() + self.assertEqual(loaded["apiKey"], "lin_api_loose") + self.assertEqual(cfg_dir.stat().st_mode & 0o777, 0o700) + self.assertEqual(cfg_path.stat().st_mode & 0o777, 0o600) + # Sanity: not group/world readable. + mode = cfg_path.stat().st_mode + self.assertFalse(mode & stat.S_IRGRP) + self.assertFalse(mode & stat.S_IROTH) + class MigrateAgentLabelsClassifyTest(unittest.TestCase): ROSTER = ["Claude", "Codex"]