Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 38 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:"
Expand Down
46 changes: 41 additions & 5 deletions linear
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <name>` resolving without a per-call lookup.
Expand All @@ -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 {}
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions test_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading