uno-cli 1.0.0__tar.gz → 1.0.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uno-cli
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line.
5
5
  Project-URL: Homepage, https://clawdtools.uno
6
6
  Project-URL: Documentation, https://clawdtools.uno/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uno-cli"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "Uno CLI — Agent tool gateway by ClawdChat. Access 2000+ real-world tools from the command line."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.8"
@@ -120,7 +120,15 @@ class TestLoginWithKey:
120
120
 
121
121
  monkeypatch.setattr(cli, "UnoAPI", _FakeAPI)
122
122
  result = cli.AuthManager().login_with_key("uno_key_abc")
123
- assert result == {"success": True, "name": "Alice", "email": "a@e.com"}
123
+ assert result["success"] is True
124
+ assert result["name"] == "Alice"
125
+ assert result["email"] == "a@e.com"
126
+ assert result["credential_stored_at"] == cli.CRED_FILE
127
+ assert result["credential_file_mode"] == "0600"
128
+ # Critical: the raw API key must never appear in the login response
129
+ # (it is persisted to the credentials file below, but never echoed).
130
+ import json as _json
131
+ assert "uno_key_abc" not in _json.dumps(result)
124
132
  with open(cli.CRED_FILE) as f:
125
133
  data = json.load(f)
126
134
  assert data[0]["api_key"] == "uno_key_abc"
@@ -0,0 +1,148 @@
1
+ """Security-posture tests — stdout must NEVER contain a full API token.
2
+
3
+ Any raw token would let an attacker who can read the terminal, shell history,
4
+ or an agent's stdout buffer hijack the user's account. The CLI funnels every
5
+ response through ``_strip_secrets`` in ``_out`` so the invariant holds even
6
+ if a future contributor forgets to mask manually.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from uno_cli import cli
17
+
18
+
19
+ RAW_TOKEN = "uno_abcdefghijklmnopqrstuvwxyz0123456789WXYZ"
20
+
21
+
22
+ # ── _mask_token ────────────────────────────────────────────────────
23
+
24
+
25
+ class TestMaskToken:
26
+ def test_long_token_shows_prefix_and_suffix_only(self):
27
+ out = cli._mask_token(RAW_TOKEN)
28
+ assert out.startswith(RAW_TOKEN[:8])
29
+ assert out.endswith(RAW_TOKEN[-4:])
30
+ assert "..." in out
31
+ assert RAW_TOKEN not in out
32
+
33
+ def test_short_value_becomes_triple_asterisk(self):
34
+ assert cli._mask_token("short") == "***"
35
+
36
+ def test_none_stays_none(self):
37
+ assert cli._mask_token(None) is None
38
+
39
+ def test_non_string_values_unchanged(self):
40
+ assert cli._mask_token(42) == 42
41
+ assert cli._mask_token(["list"]) == ["list"]
42
+
43
+
44
+ # ── _strip_secrets ─────────────────────────────────────────────────
45
+
46
+
47
+ class TestStripSecrets:
48
+ def test_top_level_api_key_field_is_masked(self):
49
+ out = cli._strip_secrets({"api_key": RAW_TOKEN, "name": "alice"})
50
+ assert out["api_key"] != RAW_TOKEN
51
+ assert RAW_TOKEN not in json.dumps(out)
52
+ assert out["name"] == "alice"
53
+
54
+ @pytest.mark.parametrize(
55
+ "field",
56
+ [
57
+ "api_key",
58
+ "apiKey",
59
+ "API_KEY",
60
+ "access_token",
61
+ "refresh_token",
62
+ "secret",
63
+ "client_secret",
64
+ "password",
65
+ "token",
66
+ ],
67
+ )
68
+ def test_known_secret_field_names_are_all_masked(self, field):
69
+ out = cli._strip_secrets({field: RAW_TOKEN})
70
+ assert RAW_TOKEN not in json.dumps(out), f"{field} not masked"
71
+
72
+ def test_nested_dict_secret_is_masked(self):
73
+ out = cli._strip_secrets({"data": {"api_key": RAW_TOKEN, "email": "a@b.c"}})
74
+ assert RAW_TOKEN not in json.dumps(out)
75
+ assert out["data"]["email"] == "a@b.c"
76
+
77
+ def test_list_of_dicts_is_masked(self):
78
+ items = [
79
+ {"name": "k1", "api_key": RAW_TOKEN},
80
+ {"name": "k2", "api_key": RAW_TOKEN + "_2"},
81
+ ]
82
+ out = cli._strip_secrets({"keys": items})
83
+ assert RAW_TOKEN not in json.dumps(out)
84
+ assert out["keys"][0]["name"] == "k1"
85
+
86
+ def test_non_secret_lookalike_field_untouched(self):
87
+ out = cli._strip_secrets({"description": "the api_key field is secret"})
88
+ assert out["description"] == "the api_key field is secret"
89
+
90
+ def test_key_id_is_not_a_secret(self):
91
+ out = cli._strip_secrets({"key_id": "kid_abc", "description": "x"})
92
+ assert out["key_id"] == "kid_abc"
93
+
94
+
95
+ # ── _out chokepoint ────────────────────────────────────────────────
96
+
97
+
98
+ class TestOutChokepoint:
99
+ def test_out_masks_api_key_before_printing(self, capsys):
100
+ cli._out({"success": True, "api_key": RAW_TOKEN})
101
+ captured = capsys.readouterr()
102
+ assert RAW_TOKEN not in captured.out
103
+ payload = json.loads(captured.out)
104
+ assert payload["success"] is True
105
+ assert payload["api_key"] != RAW_TOKEN
106
+
107
+ def test_out_masks_nested_access_token(self, capsys):
108
+ cli._out({"data": {"tokens": [{"access_token": RAW_TOKEN}]}})
109
+ out = capsys.readouterr().out
110
+ assert RAW_TOKEN not in out
111
+
112
+ def test_out_preserves_non_secret_structure(self, capsys):
113
+ cli._out({"success": True, "data": {"name": "alice", "credits": 500}})
114
+ payload = json.loads(capsys.readouterr().out)
115
+ assert payload["data"]["name"] == "alice"
116
+ assert payload["data"]["credits"] == 500
117
+
118
+
119
+ # ── Callsite regression ────────────────────────────────────────────
120
+
121
+
122
+ class TestLoginReturnShape:
123
+ """The return dicts from AuthManager login paths must not carry the raw
124
+ token even if _out is bypassed (defense in depth). We verify by building a
125
+ real return payload the way ``login_device_poll`` would."""
126
+
127
+ def test_login_device_poll_success_shape_has_no_api_key(self):
128
+ source = (cli.__file__,)
129
+ import pathlib
130
+
131
+ text = pathlib.Path(source[0]).read_text(encoding="utf-8")
132
+ # Confirm the obvious leak patterns are gone:
133
+ assert '"api_key": access_token' not in text, (
134
+ "login_device_poll must not return the raw access_token under an "
135
+ "api_key field; agents should read ~/.uno/credentials.json instead."
136
+ )
137
+
138
+ def test_credential_stored_at_is_advertised(self):
139
+ """Positive marker: successful login responses include a
140
+ ``credential_stored_at`` field pointing at the file on disk."""
141
+ import pathlib
142
+
143
+ text = pathlib.Path(cli.__file__).read_text(encoding="utf-8")
144
+ assert text.count('"credential_stored_at": CRED_FILE') >= 3, (
145
+ "All login success paths (login_with_key + login_device_poll main "
146
+ "+ fallback) should advertise the credential file location so "
147
+ "callers know where the token actually lives."
148
+ )
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
@@ -45,6 +45,57 @@ ALLOWED_TOOLS_ENV = "UNO_ALLOWED_TOOLS"
45
45
  CALLER_SKILL_ENV = "UNO_CALLER_SKILL"
46
46
 
47
47
 
48
+ # ── Secret Masking (security posture: no token ever leaves via stdout) ──
49
+ #
50
+ # Every response written with ``_out`` is passed through ``_strip_secrets``.
51
+ # Any dict field whose key name matches a known secret-ish name has its value
52
+ # replaced with a short masked preview (``uno_abcd...wxyz``). The raw token
53
+ # itself is still written to ``~/.uno/credentials.json`` (mode 0600) by
54
+ # ``AuthManager.save_account``; CI jobs that genuinely need the raw value can
55
+ # read the credentials file directly — the CLI does not provide a ``--show-
56
+ # key`` escape hatch so that the code surface is trivially auditable.
57
+ _SECRET_FIELD_NAMES = frozenset({
58
+ "api_key",
59
+ "apikey",
60
+ "access_token",
61
+ "refresh_token",
62
+ "secret",
63
+ "client_secret",
64
+ "password",
65
+ "token",
66
+ })
67
+
68
+
69
+ def _mask_token(value):
70
+ """Return a short preview of a token-like string.
71
+
72
+ ``uno_abcdefghij…wxyz`` for inputs ≥ 12 chars, else ``***``.
73
+ Non-string values are returned unchanged (``None``, numeric key-ids, etc.).
74
+ """
75
+ if not isinstance(value, str):
76
+ return value
77
+ if len(value) < 12:
78
+ return "***"
79
+ return f"{value[:8]}...{value[-4:]}"
80
+
81
+
82
+ def _strip_secrets(obj):
83
+ """Recursively replace any secret-ish field value with a masked preview.
84
+
85
+ Applied to **every** payload written to stdout via ``_out``. This is the
86
+ single chokepoint so there is no code path in the CLI that can leak a raw
87
+ token to stdout, regardless of what the server returns.
88
+ """
89
+ if isinstance(obj, dict):
90
+ return {
91
+ k: (_mask_token(v) if isinstance(k, str) and k.lower() in _SECRET_FIELD_NAMES else _strip_secrets(v))
92
+ for k, v in obj.items()
93
+ }
94
+ if isinstance(obj, list):
95
+ return [_strip_secrets(x) for x in obj]
96
+ return obj
97
+
98
+
48
99
  # ── HTTP Client ────────────────────────────────────────────────────
49
100
 
50
101
 
@@ -259,7 +310,13 @@ class AuthManager:
259
310
  return {"error": "API Key invalid or user not found"}
260
311
 
261
312
  self.save_account(key, name, email, user_id)
262
- return {"success": True, "name": name, "email": email}
313
+ return {
314
+ "success": True,
315
+ "name": name,
316
+ "email": email,
317
+ "credential_stored_at": CRED_FILE,
318
+ "credential_file_mode": "0600",
319
+ }
263
320
 
264
321
  def login_device_start(self):
265
322
  """Step 1: request device code. Returns JSON with verification URL.
@@ -332,6 +389,10 @@ class AuthManager:
332
389
  return {"error": "No access token in response"}
333
390
 
334
391
  verify_api = UnoAPI(api_key=access_token, base_url=self.base_url)
392
+ # Token is persisted to the credentials file but intentionally NOT
393
+ # returned in the response dict. ``_out`` would mask it anyway, but
394
+ # keeping the raw value out of every return path means there is no
395
+ # source-level location where a token can leak into stdout.
335
396
  try:
336
397
  me = verify_api.get("/v1/auth/me")
337
398
  name = me.get("name", "")
@@ -342,11 +403,16 @@ class AuthManager:
342
403
  "success": True,
343
404
  "name": name,
344
405
  "email": email,
345
- "api_key": access_token,
406
+ "credential_stored_at": CRED_FILE,
407
+ "credential_file_mode": "0600",
346
408
  }
347
409
  except APIError:
348
410
  self.save_account(access_token, "user", "", "")
349
- return {"success": True, "api_key": access_token}
411
+ return {
412
+ "success": True,
413
+ "credential_stored_at": CRED_FILE,
414
+ "credential_file_mode": "0600",
415
+ }
350
416
 
351
417
  return {"error": "Authorization timeout"}
352
418
 
@@ -449,8 +515,15 @@ def _fail(msg, **extra):
449
515
 
450
516
 
451
517
  def _out(obj):
518
+ """Write a JSON response to stdout, with any secret fields masked.
519
+
520
+ All CLI responses funnel through this function. ``_strip_secrets`` is the
521
+ single place where token-like fields are redacted before they can reach
522
+ the terminal, shell history, log files, or an agent's stdout buffer.
523
+ """
524
+ safe = _strip_secrets(obj)
452
525
  indent = None if getattr(_out, "compact", False) else 2
453
- print(json.dumps(obj, indent=indent, ensure_ascii=False, default=str))
526
+ print(json.dumps(safe, indent=indent, ensure_ascii=False, default=str))
454
527
 
455
528
 
456
529
  def _get_api(args):
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes