fix(cli): allow persisted contributor tier consent

This commit is contained in:
fangliquanflq
2026-08-15 12:44:04 +08:00
committed by Teknium
parent 3cbd86aac8
commit 25672c0e7f
4 changed files with 158 additions and 7 deletions
+4
View File
@@ -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
View File
@@ -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: