uno-cli 1.0.0__tar.gz → 1.0.2__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.2
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.2"
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,138 @@
1
+ """--dry-run preview tests.
2
+
3
+ ``uno call --dry-run <tool>`` is the primary user-confirmation surface: it
4
+ formats the planned invocation (tool + arguments) as JSON without reading any
5
+ credential, opening any socket, or spending any credit. These tests are the
6
+ safety net that guarantees the dry-run remains strictly local.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from uno_cli import cli
17
+
18
+
19
+ # ── Shared fake API that explodes if ever called ───────────────────
20
+
21
+
22
+ class _PanicAPI:
23
+ """Any method on this class raises — a dry-run must never hit it."""
24
+
25
+ def __init__(self, *a, **kw):
26
+ raise AssertionError(
27
+ "dry-run called the network client; it must not touch the network"
28
+ )
29
+
30
+
31
+ # ── Parser surface ─────────────────────────────────────────────────
32
+
33
+
34
+ class TestDryRunFlag:
35
+ def test_dry_run_flag_defaults_to_false(self):
36
+ parser = cli.build_parser()
37
+ args = parser.parse_args(["call", "weather.get", "--args", "{}"])
38
+ assert args.dry_run is False
39
+
40
+ def test_dry_run_flag_parsed_when_present(self):
41
+ parser = cli.build_parser()
42
+ args = parser.parse_args(["call", "--dry-run", "weather.get", "--args", "{}"])
43
+ assert args.dry_run is True
44
+
45
+ def test_dry_run_flag_trailing_position_also_works(self):
46
+ parser = cli.build_parser()
47
+ args = parser.parse_args(["call", "weather.get", "--args", "{}", "--dry-run"])
48
+ assert args.dry_run is True
49
+
50
+
51
+ # ── cmd_call dry-run path ──────────────────────────────────────────
52
+
53
+
54
+ class TestDryRunCall:
55
+ def _run_dry_run(self, monkeypatch, capsys, *, tool, args_json):
56
+ """Invoke cmd_call in dry-run mode; any network attempt explodes."""
57
+ # If _get_api / UnoAPI ever gets called, the test fails loudly.
58
+ monkeypatch.setattr(cli, "_get_api", lambda a: _PanicAPI())
59
+ monkeypatch.setattr(cli, "UnoAPI", _PanicAPI)
60
+
61
+ ns = argparse.Namespace(
62
+ tool=tool,
63
+ args=args_json,
64
+ timeout=60,
65
+ dry_run=True,
66
+ base_url="https://clawdtools.uno",
67
+ )
68
+ cli.cmd_call(ns)
69
+ return json.loads(capsys.readouterr().out)
70
+
71
+ def test_dry_run_returns_preview_without_network(self, monkeypatch, capsys):
72
+ out = self._run_dry_run(
73
+ monkeypatch,
74
+ capsys,
75
+ tool="weather-free.weather_now",
76
+ args_json='{"city": "Beijing"}',
77
+ )
78
+ assert out["success"] is True
79
+ assert out["dry_run"] is True
80
+ assert out["preview"]["tool"] == "weather-free.weather_now"
81
+ assert out["preview"]["arguments"] == {"city": "Beijing"}
82
+ assert out["preview"]["timeout_seconds"] == 60
83
+ assert "No network request" in out["note"]
84
+
85
+ def test_dry_run_with_empty_args_returns_empty_dict(self, monkeypatch, capsys):
86
+ out = self._run_dry_run(
87
+ monkeypatch, capsys, tool="time.get_current_time", args_json=None
88
+ )
89
+ assert out["preview"]["arguments"] == {}
90
+
91
+ def test_dry_run_without_credentials_still_works(self, monkeypatch, capsys):
92
+ """Dry-run must not call _get_api, so even unauthenticated users can
93
+ preview an invocation."""
94
+
95
+ def _panic(*a, **kw):
96
+ raise AssertionError("dry-run read credentials; it must not.")
97
+
98
+ monkeypatch.setattr(cli.AuthManager, "load_key", _panic)
99
+ self._run_dry_run(
100
+ monkeypatch,
101
+ capsys,
102
+ tool="weather-free.weather_now",
103
+ args_json='{"city": "Tokyo"}',
104
+ )
105
+
106
+ def test_dry_run_respects_scope_guard(self, monkeypatch, capsys):
107
+ """Dry-run should still be blocked by UNO_ALLOWED_TOOLS — a skill
108
+ scoped to gpt-image-2 should not be able to preview a call to github
109
+ even in dry-run mode."""
110
+ monkeypatch.setenv("UNO_ALLOWED_TOOLS", "gpt-image-2.*")
111
+ monkeypatch.setattr(cli, "_get_api", lambda a: _PanicAPI())
112
+
113
+ ns = argparse.Namespace(
114
+ tool="github.create_issue",
115
+ args='{"title": "x"}',
116
+ timeout=60,
117
+ dry_run=True,
118
+ base_url="https://clawdtools.uno",
119
+ )
120
+ with pytest.raises(SystemExit) as excinfo:
121
+ cli.cmd_call(ns)
122
+ assert excinfo.value.code == 1
123
+ out = json.loads(capsys.readouterr().out)
124
+ assert out["error"] == "tool_not_allowed"
125
+
126
+ def test_dry_run_preview_does_not_leak_secrets(self, monkeypatch, capsys):
127
+ """If the user passes an api_key-looking arg, the normal output
128
+ stripper should still redact it on the way out."""
129
+ out = self._run_dry_run(
130
+ monkeypatch,
131
+ capsys,
132
+ tool="some-tool.run",
133
+ args_json='{"api_key": "uno_should_be_masked_longlonglong"}',
134
+ )
135
+ # _out passes everything through _strip_secrets; the api_key field
136
+ # inside preview.arguments is a legitimate tool arg, but the masker
137
+ # errs on the side of caution by key-name.
138
+ assert "uno_should_be_masked_longlonglong" not in json.dumps(out)
@@ -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.2"
@@ -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):
@@ -582,11 +655,10 @@ def cmd_call(args):
582
655
  allowed=allowed_patterns,
583
656
  hint=(
584
657
  "This skill is scoped to a subset of Uno tools. "
585
- "Install uno-cli separately to access the full 2000+ tool catalog."
658
+ "Install uno-cli separately to access the full tool catalog."
586
659
  ),
587
660
  )
588
661
 
589
- api = _get_api(args)
590
662
  arguments = {}
591
663
  raw_args = getattr(args, "args", None)
592
664
  if raw_args:
@@ -601,6 +673,30 @@ def cmd_call(args):
601
673
  _fail(f"Invalid JSON in --args: {e}")
602
674
 
603
675
  timeout = getattr(args, "timeout", 60) or 60
676
+
677
+ # Dry-run preview: format the planned invocation without authenticating,
678
+ # issuing any network request, or spending credits. This is the primary
679
+ # "user confirmation" surface — an agent is expected to dry-run a call,
680
+ # show the JSON to the human, and only re-run without --dry-run after
681
+ # explicit approval. Read-only and credentials-free by construction.
682
+ if getattr(args, "dry_run", False):
683
+ _out({
684
+ "success": True,
685
+ "dry_run": True,
686
+ "preview": {
687
+ "tool": tool_slug,
688
+ "arguments": arguments,
689
+ "timeout_seconds": timeout,
690
+ },
691
+ "note": (
692
+ "No network request was made, no credentials were read, and "
693
+ "no credits were spent. Re-run without --dry-run to actually "
694
+ "invoke the tool."
695
+ ),
696
+ })
697
+ return
698
+
699
+ api = _get_api(args)
604
700
  try:
605
701
  result = api.post("/v1/call", {"tool": tool_slug, "arguments": arguments}, timeout=timeout)
606
702
  if result.get("error"):
@@ -766,6 +862,7 @@ def build_parser():
766
862
  "Examples:\n"
767
863
  " uno call time.get_current_time --args '{\"timezone\":\"Asia/Shanghai\"}'\n"
768
864
  " uno call weather-cn.weatherArea --args '{\"area\":\"Beijing\"}'\n"
865
+ " uno call --dry-run weather-cn.weatherArea --args '{\"area\":\"Beijing\"}'\n"
769
866
  " uno call twelve-data.quote --args '{\"symbol\":\"AAPL\"}' --timeout 30\n\n"
770
867
  "Scope enforcement (for single-purpose skills):\n"
771
868
  " UNO_ALLOWED_TOOLS='gpt-image-2.*,time.*' # comma-separated glob whitelist"
@@ -774,6 +871,11 @@ def build_parser():
774
871
  )
775
872
  call_p.add_argument("tool", help="Tool slug (e.g. weather-cn.weatherArea)")
776
873
  call_p.add_argument("--args", help='JSON arguments (e.g. \'{"city":"Beijing"}\')')
874
+ call_p.add_argument(
875
+ "--dry-run",
876
+ action="store_true",
877
+ help="Preview the planned invocation without issuing any network request or spending credits.",
878
+ )
777
879
  call_p.add_argument("--timeout", type=int, default=60, help="Request timeout in seconds (default: 60)")
778
880
 
779
881
  rate_p = sub.add_parser("rate", help="Rate a tool (0-5)")
@@ -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