beakr-cli 0.3.2__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- beakr_cli-0.3.3/CHANGELOG.md +16 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/PKG-INFO +1 -1
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/pyproject.toml +1 -1
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/__init__.py +1 -1
- beakr_cli-0.3.3/src/beakr_cli/config.py +83 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/updates.py +1 -1
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_auth_cli.py +1 -1
- beakr_cli-0.3.3/tests/test_config_permissions.py +95 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/uv.lock +1 -1
- beakr_cli-0.3.2/src/beakr_cli/config.py +0 -41
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/commands/kb-audit.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/commands/kb-research.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/commands/kb-search.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/commands/kb-write.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/SKILL.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/examples/answer-org-question.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/examples/research-topic.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/examples/write-decision-page.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/reference/auditing.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/reference/provenance.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/reference/research-workflow.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.claude/skills/beakr/reference/writing-pages.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.github/workflows/publish.yml +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/.gitignore +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/LICENSE +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/README.md +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/install.sh +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/citations.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/client.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/commands/__init__.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/commands/auth.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/commands/install.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/commands/kb.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/commands/workspace.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/main.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/mcp_server.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/src/beakr_cli/output.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/__init__.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_bundled_assets_vocabulary.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_citations.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_client.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_mcp_shared_vocabulary.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_mcp_source_rendering.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_packaging.py +0 -0
- {beakr_cli-0.3.2 → beakr_cli-0.3.3}/tests/test_updates.py +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.3 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
Security fix. Upgrade with `beakr update` or `uv tool upgrade beakr-cli`.
|
|
6
|
+
|
|
7
|
+
- The API key saved by `beakr auth login` and `beakr install` lives in
|
|
8
|
+
`~/.beakr/config.json`. Earlier releases created `~/.beakr` and that file
|
|
9
|
+
with default permissions (usually readable by every user on the machine).
|
|
10
|
+
0.3.3 creates the directory `0700` and the file `0600`, writes the file
|
|
11
|
+
atomically so the key is never briefly readable, and tightens a directory and
|
|
12
|
+
file an earlier release created the first time it reads them.
|
|
13
|
+
- No other behavior changes; 0.3.3 talks to the same API as 0.3.2.
|
|
14
|
+
|
|
15
|
+
If other people have accounts on a machine where you used an earlier release,
|
|
16
|
+
consider revoking that API key in the Beakr web app and logging in with a new one.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Persistent configuration stored in ~/.beakr/config.json.
|
|
2
|
+
|
|
3
|
+
The file holds the API key, so it is readable by its owner only: the directory
|
|
4
|
+
is 0700 and the file 0600. Releases up to 0.3.2 wrote both with the default
|
|
5
|
+
umask (usually 0755/0644, readable by every local user); ``load`` and ``save``
|
|
6
|
+
tighten an existing directory and file the first time this version touches them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
CONFIG_DIR = Path.home() / ".beakr"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
20
|
+
|
|
21
|
+
_DIR_MODE = 0o700
|
|
22
|
+
_FILE_MODE = 0o600
|
|
23
|
+
# POSIX permission bits mean nothing useful on Windows, where the profile
|
|
24
|
+
# directory is already private to the user.
|
|
25
|
+
_ENFORCE_MODES = os.name != "nt"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tighten(path: Path, mode: int) -> None:
|
|
29
|
+
"""Remove group and other access from ``path`` if it has any."""
|
|
30
|
+
if not _ENFORCE_MODES:
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
current = stat.S_IMODE(path.stat().st_mode)
|
|
34
|
+
if current & 0o077:
|
|
35
|
+
path.chmod(mode)
|
|
36
|
+
except OSError:
|
|
37
|
+
# Best effort: a file owned by someone else, or on a filesystem without
|
|
38
|
+
# POSIX modes, must not stop the command from running.
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_dir() -> None:
|
|
43
|
+
"""Create ~/.beakr owner-only, or tighten it if an older release made it."""
|
|
44
|
+
CONFIG_DIR.mkdir(mode=_DIR_MODE, parents=True, exist_ok=True)
|
|
45
|
+
_tighten(CONFIG_DIR, _DIR_MODE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load() -> dict[str, Any]:
|
|
49
|
+
if not CONFIG_FILE.exists():
|
|
50
|
+
return {}
|
|
51
|
+
_tighten(CONFIG_DIR, _DIR_MODE)
|
|
52
|
+
_tighten(CONFIG_FILE, _FILE_MODE)
|
|
53
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save(data: dict[str, Any]) -> None:
|
|
57
|
+
ensure_dir()
|
|
58
|
+
# mkstemp creates the file 0600; renaming it over the config means the key
|
|
59
|
+
# is never on disk with wider permissions, even for an instant.
|
|
60
|
+
fd, tmp_name = tempfile.mkstemp(dir=CONFIG_DIR, prefix=".config.", suffix=".tmp")
|
|
61
|
+
try:
|
|
62
|
+
with os.fdopen(fd, "w") as handle:
|
|
63
|
+
handle.write(json.dumps(data, indent=2) + "\n")
|
|
64
|
+
os.replace(tmp_name, CONFIG_FILE)
|
|
65
|
+
except BaseException:
|
|
66
|
+
Path(tmp_name).unlink(missing_ok=True)
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get(key: str, default: Any = None) -> Any:
|
|
71
|
+
return load().get(key, default)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def set_key(key: str, value: Any) -> None:
|
|
75
|
+
data = load()
|
|
76
|
+
data[key] = value
|
|
77
|
+
save(data)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def delete_key(key: str) -> None:
|
|
81
|
+
data = load()
|
|
82
|
+
data.pop(key, None)
|
|
83
|
+
save(data)
|
|
@@ -90,7 +90,7 @@ def _read_cache() -> tuple[str | None, datetime | None]:
|
|
|
90
90
|
|
|
91
91
|
def _write_cache(latest: str, checked_at: datetime) -> None:
|
|
92
92
|
try:
|
|
93
|
-
config.
|
|
93
|
+
config.ensure_dir()
|
|
94
94
|
_cache_file().write_text(
|
|
95
95
|
json.dumps({"latest": latest, "checked_at": checked_at.isoformat()}) + "\n"
|
|
96
96
|
)
|
|
@@ -10,7 +10,7 @@ def test_whoami_reports_environment_scope_over_stored_config(monkeypatch) -> Non
|
|
|
10
10
|
"beakr_cli.commands.auth.api_get",
|
|
11
11
|
lambda _path: {
|
|
12
12
|
"user": {"display_name": "Sarah Chen"},
|
|
13
|
-
"personal_org": {"name": "
|
|
13
|
+
"personal_org": {"name": "Example University"},
|
|
14
14
|
},
|
|
15
15
|
)
|
|
16
16
|
monkeypatch.setattr("beakr_cli.commands.auth.config.get", lambda _key: "stale-config")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""~/.beakr and its config.json hold the API key, so they are owner-only.
|
|
2
|
+
|
|
3
|
+
Regression: up to 0.3.2 ``config.save`` created ``~/.beakr`` and wrote
|
|
4
|
+
``config.json`` with the process umask (typically 0755 and 0644), so any local
|
|
5
|
+
user could read the stored API key. 0.3.3 writes them 0700/0600 and tightens
|
|
6
|
+
directories and files an older release already created.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from typer.testing import CliRunner
|
|
17
|
+
|
|
18
|
+
from beakr_cli import config
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
|
|
21
|
+
|
|
22
|
+
KEY = "bk_live_permissions_test_key_value"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mode(path) -> int:
|
|
26
|
+
return stat.S_IMODE(path.stat().st_mode)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def config_home(tmp_path, monkeypatch):
|
|
31
|
+
monkeypatch.setattr(config, "CONFIG_DIR", tmp_path / ".beakr")
|
|
32
|
+
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / ".beakr" / "config.json")
|
|
33
|
+
return tmp_path / ".beakr"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_saving_the_key_creates_an_owner_only_directory_and_file(config_home):
|
|
37
|
+
previous_umask = os.umask(0o022)
|
|
38
|
+
try:
|
|
39
|
+
config.set_key("api_key", KEY)
|
|
40
|
+
finally:
|
|
41
|
+
os.umask(previous_umask)
|
|
42
|
+
|
|
43
|
+
assert _mode(config_home) == 0o700
|
|
44
|
+
assert _mode(config.CONFIG_FILE) == 0o600
|
|
45
|
+
assert json.loads(config.CONFIG_FILE.read_text())["api_key"] == KEY
|
|
46
|
+
# The atomic write leaves no temporary file behind.
|
|
47
|
+
assert [p.name for p in config_home.iterdir()] == ["config.json"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_reading_tightens_a_directory_and_file_an_older_release_wrote(config_home):
|
|
51
|
+
config_home.mkdir(mode=0o755)
|
|
52
|
+
config_home.chmod(0o755)
|
|
53
|
+
config.CONFIG_FILE.write_text(json.dumps({"api_key": KEY}) + "\n")
|
|
54
|
+
config.CONFIG_FILE.chmod(0o644)
|
|
55
|
+
|
|
56
|
+
assert config.get("api_key") == KEY
|
|
57
|
+
|
|
58
|
+
assert _mode(config_home) == 0o700
|
|
59
|
+
assert _mode(config.CONFIG_FILE) == 0o600
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_saving_tightens_an_existing_world_readable_file(config_home):
|
|
63
|
+
config_home.mkdir(mode=0o755)
|
|
64
|
+
config_home.chmod(0o755)
|
|
65
|
+
config.CONFIG_FILE.write_text("{}\n")
|
|
66
|
+
config.CONFIG_FILE.chmod(0o644)
|
|
67
|
+
|
|
68
|
+
config.save({"api_key": KEY})
|
|
69
|
+
|
|
70
|
+
assert _mode(config_home) == 0o700
|
|
71
|
+
assert _mode(config.CONFIG_FILE) == 0o600
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_update_check_cache_creates_the_directory_owner_only(config_home):
|
|
75
|
+
from datetime import datetime, timezone
|
|
76
|
+
|
|
77
|
+
from beakr_cli import updates
|
|
78
|
+
|
|
79
|
+
updates._write_cache("9.9.9", datetime.now(timezone.utc))
|
|
80
|
+
|
|
81
|
+
assert _mode(config_home) == 0o700
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_failed_login_does_not_echo_the_api_key(config_home, monkeypatch):
|
|
85
|
+
from beakr_cli.commands import auth
|
|
86
|
+
|
|
87
|
+
def reject(_path, *args, **kwargs):
|
|
88
|
+
raise RuntimeError("401 Unauthorized")
|
|
89
|
+
|
|
90
|
+
monkeypatch.setattr(auth, "api_get", reject)
|
|
91
|
+
result = CliRunner().invoke(auth.app, ["login", "--api-key", KEY])
|
|
92
|
+
|
|
93
|
+
assert result.exit_code == 1
|
|
94
|
+
assert KEY not in result.output
|
|
95
|
+
assert config.get("api_key") is None
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""Persistent configuration stored in ~/.beakr/config.json."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
CONFIG_DIR = Path.home() / ".beakr"
|
|
10
|
-
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _ensure_dir() -> None:
|
|
14
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def load() -> dict[str, Any]:
|
|
18
|
-
if not CONFIG_FILE.exists():
|
|
19
|
-
return {}
|
|
20
|
-
return json.loads(CONFIG_FILE.read_text())
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def save(data: dict[str, Any]) -> None:
|
|
24
|
-
_ensure_dir()
|
|
25
|
-
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get(key: str, default: Any = None) -> Any:
|
|
29
|
-
return load().get(key, default)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def set_key(key: str, value: Any) -> None:
|
|
33
|
-
data = load()
|
|
34
|
-
data[key] = value
|
|
35
|
-
save(data)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def delete_key(key: str) -> None:
|
|
39
|
-
data = load()
|
|
40
|
-
data.pop(key, None)
|
|
41
|
-
save(data)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|