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.
- {uno_cli-1.0.0 → uno_cli-1.0.1}/PKG-INFO +1 -1
- {uno_cli-1.0.0 → uno_cli-1.0.1}/pyproject.toml +1 -1
- {uno_cli-1.0.0 → uno_cli-1.0.1}/tests/test_auth.py +9 -1
- uno_cli-1.0.1/tests/test_token_masking.py +148 -0
- uno_cli-1.0.1/uno_cli/_version.py +1 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/uno_cli/cli.py +77 -4
- uno_cli-1.0.0/uno_cli/_version.py +0 -1
- {uno_cli-1.0.0 → uno_cli-1.0.1}/.gitignore +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/LICENSE +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/README.md +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/tests/__init__.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/tests/test_cli.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/tests/test_scope.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/uno_cli/__init__.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.1}/uno_cli/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: uno-cli
|
|
3
|
-
Version: 1.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
|
|
@@ -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
|
|
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 {
|
|
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
|
-
"
|
|
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 {
|
|
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(
|
|
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
|