aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-16 00:00:45 -0500
committerCraig Jennings <c@cjennings.net>2026-06-16 00:00:45 -0500
commit41dd030cd6414f0acf60a32dcc4de415966601d0 (patch)
treee2756726bb8cc59124a2051486b017b730f734ba /scripts
parent546bcf9bbe651eaaa0129346536c9e7e98748555 (diff)
downloadpearl-41dd030cd6414f0acf60a32dcc4de415966601d0.tar.gz
pearl-41dd030cd6414f0acf60a32dcc4de415966601d0.zip
chore(scripts): warn when apikey.txt is group/other-readable
The seed script reads the Linear key from the environment, but the README sources it from a gitignored `apikey.txt` that was landing at mode 644. `main` now warns on startup when that file is group- or other-accessible, so a loose key file gets flagged instead of sitting silently exposed. The warning points at `chmod 600`.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/seed_pearl_workspace.py20
-rw-r--r--scripts/tests/test_seed_pearl_workspace.py22
2 files changed, 42 insertions, 0 deletions
diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py
index bc5300a..97079b6 100644
--- a/scripts/seed_pearl_workspace.py
+++ b/scripts/seed_pearl_workspace.py
@@ -41,6 +41,7 @@ Usage:
import argparse
import json
import os
+import stat
import sys
import urllib.request
@@ -363,12 +364,31 @@ def seed(client):
"default_issue_state": INTAKE_STATE if default_set else "unchanged"}
+def warn_if_key_file_world_readable(path, stream=None):
+ """Warn when PATH exists and is readable by group or others.
+
+ The key file holds a Linear API key, so group/other access is a leak risk.
+ Returns True when a warning was emitted, False otherwise (absent or 600)."""
+ if stream is None:
+ stream = sys.stderr
+ try:
+ mode = os.stat(path).st_mode
+ except OSError:
+ return False
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
+ print(f"warning: {path} is group/other-accessible; chmod 600 it "
+ "(it holds your Linear API key)", file=stream)
+ return True
+ return False
+
+
def main(argv=None):
parser = argparse.ArgumentParser(description="Seed a Pearl Linear workspace.")
parser.add_argument("--dry-run", action="store_true",
help="print the reconcile plan without changing anything")
args = parser.parse_args(argv)
+ warn_if_key_file_world_readable("apikey.txt")
api_key = os.environ.get("LINEAR_API_KEY")
if not api_key:
print("LINEAR_API_KEY is not set", file=sys.stderr)
diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py
index 5f4147e..e52d78a 100644
--- a/scripts/tests/test_seed_pearl_workspace.py
+++ b/scripts/tests/test_seed_pearl_workspace.py
@@ -296,3 +296,25 @@ def test_client_raises_on_graphql_errors():
client = seed.LinearClient("key", transport=fake)
with pytest.raises(seed.LinearError, match="bad key"):
client.execute("query Teams { teams { nodes { id } } }", {})
+
+
+# --- key-file permission warning ----------------------------------------------
+
+def test_warn_key_file_world_readable_warns(tmp_path, capsys):
+ p = tmp_path / "apikey.txt"
+ p.write_text("lin_api_secret")
+ os.chmod(p, 0o644)
+ assert seed.warn_if_key_file_world_readable(str(p)) is True
+ assert "600" in capsys.readouterr().err
+
+
+def test_warn_key_file_mode_600_is_silent(tmp_path, capsys):
+ p = tmp_path / "apikey.txt"
+ p.write_text("lin_api_secret")
+ os.chmod(p, 0o600)
+ assert seed.warn_if_key_file_world_readable(str(p)) is False
+ assert capsys.readouterr().err == ""
+
+
+def test_warn_key_file_absent_is_silent(tmp_path):
+ assert seed.warn_if_key_file_world_readable(str(tmp_path / "nope.txt")) is False