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.
- {uno_cli-1.0.0 → uno_cli-1.0.2}/PKG-INFO +1 -1
- {uno_cli-1.0.0 → uno_cli-1.0.2}/pyproject.toml +1 -1
- {uno_cli-1.0.0 → uno_cli-1.0.2}/tests/test_auth.py +9 -1
- uno_cli-1.0.2/tests/test_dry_run.py +138 -0
- uno_cli-1.0.2/tests/test_token_masking.py +148 -0
- uno_cli-1.0.2/uno_cli/_version.py +1 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/uno_cli/cli.py +108 -6
- uno_cli-1.0.0/uno_cli/_version.py +0 -1
- {uno_cli-1.0.0 → uno_cli-1.0.2}/.gitignore +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/LICENSE +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/README.md +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/tests/__init__.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/tests/test_cli.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/tests/test_scope.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/uno_cli/__init__.py +0 -0
- {uno_cli-1.0.0 → uno_cli-1.0.2}/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.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
|
|
@@ -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,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 {
|
|
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):
|
|
@@ -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
|
|
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
|