fix(cli): allow persisted contributor tier consent
This commit is contained in:
@@ -2285,6 +2285,10 @@ DEFAULT_CONFIG = {
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": True,
|
||||
# Persisted acknowledgement for unattended model overrides whose tier
|
||||
# lets the vendor train on prompts/completions. The startup guard still
|
||||
# prints the full warning on every run and never bypasses cost guards.
|
||||
"allow_data_training_tiers_noninteractive": False,
|
||||
# Human approval presentation transport. "builtin" preserves the
|
||||
# current CLI/TUI/gateway/ACP surfaces. A plugin transport is used only
|
||||
# when named explicitly here. Transport timeout/error/invalid response
|
||||
|
||||
+42
-7
@@ -1102,18 +1102,27 @@ def _confirm_startup_expensive_model_override(args) -> None:
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.model_selection_guards import combined_selection_warning
|
||||
from hermes_cli.model_selection_guards import (
|
||||
combined_message,
|
||||
selection_warnings,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("startup model cost guard unavailable: %s", exc)
|
||||
return
|
||||
|
||||
try:
|
||||
model_cfg = (load_config().get("model") or {})
|
||||
config = load_config()
|
||||
except Exception as exc:
|
||||
logger.warning("startup model cost guard could not load config: %s", exc)
|
||||
model_cfg = {}
|
||||
config = {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
model_cfg = config.get("model") or {}
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
security_cfg = config.get("security") or {}
|
||||
if not isinstance(security_cfg, dict):
|
||||
security_cfg = {}
|
||||
|
||||
model = explicit_model or (model_cfg.get("default") or "").strip()
|
||||
if not model:
|
||||
@@ -1122,7 +1131,7 @@ def _confirm_startup_expensive_model_override(args) -> None:
|
||||
try:
|
||||
# Unified registry: cost guard + id-keyed guards (e.g. the
|
||||
# data-training-tier warning) all fire at startup too.
|
||||
warning = combined_selection_warning(
|
||||
warnings = selection_warnings(
|
||||
model,
|
||||
provider=provider,
|
||||
base_url=(model_cfg.get("base_url") or ""),
|
||||
@@ -1131,15 +1140,41 @@ def _confirm_startup_expensive_model_override(args) -> None:
|
||||
except Exception as exc:
|
||||
logger.warning("startup model cost guard failed for %s/%s: %s", provider, model, exc)
|
||||
return
|
||||
if warning is None:
|
||||
if not warnings:
|
||||
return
|
||||
|
||||
# Cost and provider-routing confirmation is intentionally independent of
|
||||
# --yolo / --accept-hooks: those flags approve local command/tool risk, not
|
||||
# paid aggregator spend or a surprising provider route.
|
||||
message = warning.message
|
||||
if not sys.stdin.isatty():
|
||||
is_interactive = sys.stdin.isatty()
|
||||
allow_unattended_data_training = (
|
||||
security_cfg.get("allow_data_training_tiers_noninteractive") is True
|
||||
)
|
||||
if not is_interactive and allow_unattended_data_training:
|
||||
acknowledged = [
|
||||
warning for warning in warnings if warning.kind == "data_policy"
|
||||
]
|
||||
if acknowledged:
|
||||
sys.stderr.write(combined_message(acknowledged) + "\n")
|
||||
sys.stderr.write(
|
||||
"Proceeding in non-interactive mode because "
|
||||
"security.allow_data_training_tiers_noninteractive is true.\n"
|
||||
)
|
||||
warnings = [
|
||||
warning for warning in warnings if warning.kind != "data_policy"
|
||||
]
|
||||
if not warnings:
|
||||
return
|
||||
|
||||
message = combined_message(warnings)
|
||||
if not is_interactive:
|
||||
sys.stderr.write(message + "\n")
|
||||
if any(warning.kind == "data_policy" for warning in warnings):
|
||||
sys.stderr.write(
|
||||
"To acknowledge data-training tiers for unattended runs, set "
|
||||
"security.allow_data_training_tiers_noninteractive to true "
|
||||
"in config.yaml.\n"
|
||||
)
|
||||
sys.stderr.write(
|
||||
"Refusing this startup model override in non-interactive mode. "
|
||||
"Run interactively and confirm if you intend to use it.\n"
|
||||
|
||||
@@ -80,6 +80,20 @@ def codex_config(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def _set_startup_config(
|
||||
monkeypatch, *, provider="custom", default="safe-model", base_url="", ack=None
|
||||
):
|
||||
model = {"provider": provider, "default": default}
|
||||
if base_url:
|
||||
model["base_url"] = base_url
|
||||
config = {"model": model}
|
||||
if ack is not None:
|
||||
config["security"] = {
|
||||
"allow_data_training_tiers_noninteractive": ack,
|
||||
}
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
|
||||
|
||||
def test_cmd_chat_rejects_noninteractive_gpt55_pro_startup_override(
|
||||
main_mod, fake_cli, codex_config, monkeypatch, capsys
|
||||
):
|
||||
@@ -109,6 +123,92 @@ def test_cmd_chat_rejects_noninteractive_gpt55_pro_even_with_yolo(
|
||||
assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_allows_acknowledged_data_training_tier_noninteractively(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(monkeypatch, ack=True)
|
||||
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert fake_cli["model"] == "muse-spark-1.2-contributor"
|
||||
err = capsys.readouterr().err
|
||||
assert "TRAINS ON YOUR DATA" in err
|
||||
assert "security.allow_data_training_tiers_noninteractive" in err
|
||||
|
||||
|
||||
def test_cmd_chat_rejects_unacknowledged_data_training_tier_with_opt_in_hint(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(monkeypatch)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
err = capsys.readouterr().err
|
||||
assert "TRAINS ON YOUR DATA" in err
|
||||
assert "security.allow_data_training_tiers_noninteractive" in err
|
||||
|
||||
|
||||
def test_data_training_acknowledgement_does_not_bypass_cost_guard(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(
|
||||
monkeypatch,
|
||||
provider="openai-codex",
|
||||
default="gpt-5.5",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
ack=True,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_data_training_acknowledgement_requires_literal_true(
|
||||
main_mod, fake_cli, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(monkeypatch, ack="true")
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
|
||||
|
||||
def test_data_training_acknowledgement_does_not_skip_interactive_confirmation(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
|
||||
_set_startup_config(monkeypatch, ack=True)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "Model override cancelled" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_allows_interactive_gpt55_pro_when_confirmed(
|
||||
main_mod, fake_cli, codex_config, monkeypatch
|
||||
):
|
||||
|
||||
@@ -55,6 +55,18 @@ When you switch models **inside an active session** (Herm TUI model picker, `her
|
||||
Prompt caches are keyed to the model serving the request, so any mid-conversation model change — an explicit `/model` switch, an [automatic fallback](./features/fallback-providers.md), or a [credential-pool](./features/credential-pools.md) rotation onto a different account — means the next message re-reads the entire conversation at full input-token price instead of the cached (~75–90% discounted) rate. On a long session this one-time re-read can dwarf the per-token difference between the two models. Switch when you need to, but prefer doing it early in a conversation or right after starting a fresh session.
|
||||
:::
|
||||
|
||||
### Unattended data-training tiers
|
||||
|
||||
Models such as `muse-spark-1.2-contributor` are discounted because the vendor may train on your prompts and completions. Interactive model selection always shows a confirmation prompt. Non-interactive startup paths such as Kanban workers and cron agents fail closed because they cannot ask that question.
|
||||
|
||||
If training on the unattended workload's data is acceptable, record a persistent acknowledgement:
|
||||
|
||||
```bash
|
||||
hermes config set security.allow_data_training_tiers_noninteractive true
|
||||
```
|
||||
|
||||
Hermes still prints the full data-policy warning and the acknowledgement key on every unattended startup, so worker logs retain an audit trail. This setting does not approve expensive-model or provider-routing warnings, and it does not replace the interactive confirmation prompt. Revoke it with `hermes config unset security.allow_data_training_tiers_noninteractive`.
|
||||
|
||||
## Setting auxiliary models
|
||||
|
||||
Click **Show auxiliary** to reveal the 11 task slots:
|
||||
|
||||
Reference in New Issue
Block a user