mirror of
https://github.com/katanemo/plano.git
synced 2026-06-11 15:05:14 +02:00
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Detect local-agent provider entries in a Plano config and warn the
|
|
operator that the host is about to spawn a local CLI binary with the same
|
|
filesystem, shell, and network capabilities as the user running planoai.
|
|
|
|
Local-agent providers (e.g. ``claude-cli``) are an entirely different
|
|
trust class from stateless network LLM providers (``openai``,
|
|
``anthropic``, ``gemini``, ...): the bridge runs inside brightstaff and
|
|
shells out to a local binary for every request, so a misconfigured
|
|
production deployment would expose the host to whatever the spawned
|
|
agent can do — which, for tools like Claude Code, is "anything the
|
|
operator can do at the shell".
|
|
|
|
This module is intentionally additive and side-effect free until the
|
|
caller invokes :func:`maybe_warn_local_agent_providers`. The set of
|
|
known local-agent provider interfaces lives in
|
|
:data:`LOCAL_AGENT_PROVIDER_INTERFACES`; adding a future entry (codex,
|
|
chatgpt-cli, opencode, hermes, ...) is a one-line change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Iterable
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
|
|
from planoai.consts import PLANO_STATE_DIR
|
|
|
|
# Provider interfaces whose runtime spawns a local CLI subprocess with
|
|
# host filesystem / shell access. The string here is matched against the
|
|
# config's ``provider_interface`` field AND against the ``<prefix>/...``
|
|
# in ``model:`` and ``name:`` fields, so configs that rely on the
|
|
# Python-side autofill (``model: claude-cli/*`` only) are still detected
|
|
# before that autofill runs.
|
|
#
|
|
# Add new entries here as additional local-agent bridges are implemented
|
|
# (e.g. a future ``codex-cli`` or ``chatgpt-cli`` bridge that spawns the
|
|
# Codex CLI). This is the *only* line that needs to change to extend the
|
|
# warning's coverage.
|
|
LOCAL_AGENT_PROVIDER_INTERFACES: tuple[str, ...] = ("claude-cli",)
|
|
|
|
# Persistent ack lives next to the rest of the per-user planoai state
|
|
# (run/, bin/, plugins/, ...). Operators can ``rm`` this file to undo.
|
|
ACK_FILE_PATH = os.path.join(PLANO_STATE_DIR, "local_agent_ack.json")
|
|
|
|
# Env-var fallback for the ``--ack-local-agents`` CLI flag. Truthy values
|
|
# are 1/true/yes (case-insensitive); everything else is treated as unset.
|
|
ACK_ENV_VAR = "PLANO_ACK_LOCAL_AGENTS"
|
|
|
|
# Where the docs page lives. Printed verbatim in the warning panel — the
|
|
# relative path resolves cleanly when an operator opens it from the repo
|
|
# root, and the GitHub URL is a valid fallback for users running planoai
|
|
# outside a clone.
|
|
DOCS_RELATIVE_PATH = "docs/source/resources/local_agent_providers.rst"
|
|
DOCS_LEARN_MORE = (
|
|
"https://github.com/katanemo/plano/blob/main/docs/source/resources/"
|
|
"local_agent_providers.rst"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LocalAgentProvider:
|
|
"""A single ``model_providers`` entry that resolves to a local-agent
|
|
bridge. ``name`` and ``model`` come straight from the config, while
|
|
``interface`` is the canonical key used for ack persistence."""
|
|
|
|
interface: str
|
|
name: str
|
|
model: str
|
|
|
|
|
|
def _truthy_env(value: str | None) -> bool:
|
|
if not value:
|
|
return False
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _interface_for_entry(entry: dict) -> str | None:
|
|
"""Return the canonical local-agent interface name for ``entry``, or
|
|
``None`` if the entry isn't a local-agent provider.
|
|
|
|
Matching is intentionally permissive so that minimally-configured
|
|
entries — i.e. just ``model: claude-cli/*`` before the Python
|
|
autofill runs — are still detected. The first match wins and is
|
|
returned; multiple matches against the same interface collapse.
|
|
"""
|
|
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
|
|
provider_interface = (entry.get("provider_interface") or "").strip()
|
|
provider = (entry.get("provider") or "").strip()
|
|
model = str(entry.get("model") or "").strip()
|
|
name = str(entry.get("name") or "").strip()
|
|
|
|
for interface in LOCAL_AGENT_PROVIDER_INTERFACES:
|
|
if provider_interface == interface or provider == interface:
|
|
return interface
|
|
prefix = f"{interface}/"
|
|
if model.startswith(prefix) or name.startswith(prefix):
|
|
return interface
|
|
|
|
return None
|
|
|
|
|
|
def detect_local_agent_providers(config: dict) -> list[LocalAgentProvider]:
|
|
"""Walk ``config`` and return every ``model_providers`` entry whose
|
|
``provider_interface`` falls in :data:`LOCAL_AGENT_PROVIDER_INTERFACES`.
|
|
|
|
Order is preserved so the warning lists providers in declaration
|
|
order. Both the new ``model_providers`` key and the legacy
|
|
``llm_providers`` key are consulted, mirroring the rest of the CLI.
|
|
"""
|
|
|
|
if not isinstance(config, dict):
|
|
return []
|
|
|
|
providers = config.get("model_providers")
|
|
if not isinstance(providers, list):
|
|
providers = config.get("llm_providers") or []
|
|
|
|
found: list[LocalAgentProvider] = []
|
|
for entry in providers:
|
|
interface = _interface_for_entry(entry)
|
|
if interface is None:
|
|
continue
|
|
model = str(entry.get("model") or "").strip()
|
|
name = str(entry.get("name") or "").strip() or model or interface
|
|
found.append(LocalAgentProvider(interface=interface, name=name, model=model))
|
|
return found
|
|
|
|
|
|
def _interfaces_in(providers: Iterable[LocalAgentProvider]) -> set[str]:
|
|
return {p.interface for p in providers}
|
|
|
|
|
|
def load_acknowledged_interfaces(ack_path: str = ACK_FILE_PATH) -> set[str]:
|
|
"""Read the ack file and return the set of acknowledged provider
|
|
interfaces. Missing or malformed files are treated as "no ack",
|
|
never as a hard error, so a half-written ack file degrades to "warn
|
|
again" instead of crashing ``planoai up``."""
|
|
|
|
try:
|
|
with open(ack_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return set()
|
|
|
|
if not isinstance(data, dict):
|
|
return set()
|
|
raw = data.get("acknowledged")
|
|
if not isinstance(raw, list):
|
|
return set()
|
|
return {str(item) for item in raw if isinstance(item, str)}
|
|
|
|
|
|
def write_acknowledgement(
|
|
interfaces: Iterable[str],
|
|
ack_path: str = ACK_FILE_PATH,
|
|
) -> set[str]:
|
|
"""Persist ``interfaces`` (merged with anything already on disk) to
|
|
the ack file. Returns the full acknowledged set after the write so
|
|
callers can render an "acknowledged: X, Y" line."""
|
|
|
|
merged = load_acknowledged_interfaces(ack_path) | set(interfaces)
|
|
payload = {
|
|
"acknowledged": sorted(merged),
|
|
"ack_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
}
|
|
os.makedirs(os.path.dirname(ack_path), exist_ok=True)
|
|
with open(ack_path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2, sort_keys=True)
|
|
f.write("\n")
|
|
return merged
|
|
|
|
|
|
def _render_panel(
|
|
console: Console,
|
|
pending: list[LocalAgentProvider],
|
|
) -> None:
|
|
"""Render the single warning panel for ``pending``. Callers must
|
|
ensure ``pending`` is non-empty; the caller decides whether to skip
|
|
based on the ack set."""
|
|
|
|
listed = "\n".join(
|
|
f" • [bold]{p.name}[/bold]"
|
|
+ (f" [dim]({p.model})[/dim]" if p.model and p.model != p.name else "")
|
|
+ f" [dim]→ provider_interface=[/dim][cyan]{p.interface}[/cyan]"
|
|
for p in pending
|
|
)
|
|
|
|
interfaces_csv = ", ".join(sorted({p.interface for p in pending}))
|
|
body_lines = [
|
|
"[bold yellow]This config wires up a local-agent provider.[/bold yellow]",
|
|
"",
|
|
listed,
|
|
"",
|
|
(
|
|
"Unlike stateless network providers ([cyan]openai[/cyan], "
|
|
"[cyan]anthropic[/cyan], [cyan]gemini[/cyan], ...), these entries "
|
|
"spawn a local CLI binary as a subprocess of brightstaff. The "
|
|
"subprocess inherits the operator's permissions and can:"
|
|
),
|
|
" • read and write any file the operator can touch",
|
|
" • execute arbitrary shell commands as the operator's user",
|
|
" • use the host's auth keychain / login session",
|
|
" • make outbound network calls from the host's IP",
|
|
"",
|
|
(
|
|
"[bold]Intended for local development only — not production.[/bold] "
|
|
"Treat this as the same trust class as OpenClaw / OpenCode / "
|
|
"Hermes (agent integrations), not a stateless LLM provider."
|
|
),
|
|
"",
|
|
f"[dim]Learn more:[/dim] [bold]{DOCS_LEARN_MORE}[/bold]",
|
|
f"[dim]Or in this repo:[/dim] [bold]{DOCS_RELATIVE_PATH}[/bold]",
|
|
"",
|
|
"[dim]Dismiss permanently:[/dim]",
|
|
f" [cyan]planoai up --ack-local-agents[/cyan] [dim]# writes {ACK_FILE_PATH}[/dim]",
|
|
f" [dim]or:[/dim] [cyan]{ACK_ENV_VAR}=1 planoai up[/cyan]",
|
|
f"[dim]Undo with:[/dim] [cyan]rm {ACK_FILE_PATH}[/cyan]",
|
|
]
|
|
|
|
console.print(
|
|
Panel(
|
|
"\n".join(body_lines),
|
|
title=f"⚠ Local-agent provider detected ({interfaces_csv})",
|
|
title_align="left",
|
|
border_style="yellow",
|
|
padding=(1, 2),
|
|
)
|
|
)
|
|
|
|
|
|
def maybe_warn_local_agent_providers(
|
|
config: dict,
|
|
console: Console,
|
|
*,
|
|
ack_flag: bool = False,
|
|
ack_path: str = ACK_FILE_PATH,
|
|
env: dict | None = None,
|
|
) -> bool:
|
|
"""Show the local-agent warning panel if appropriate and return
|
|
``True`` iff the panel was rendered.
|
|
|
|
Resolution order, top to bottom:
|
|
|
|
1. No local-agent providers in config → no-op.
|
|
2. ``ack_flag`` (the ``--ack-local-agents`` CLI flag) **or** the
|
|
:data:`ACK_ENV_VAR` env var truthy → write/update the ack file
|
|
so it covers every triggering interface, print one ✓ confirmation
|
|
line, suppress the panel.
|
|
3. Existing ack file already covers every triggering interface →
|
|
print a single dim INFO line and suppress the panel.
|
|
4. Otherwise → render the panel for the *un-acked* interfaces only
|
|
(e.g. acknowledged ``claude-cli`` doesn't suppress a fresh
|
|
warning when the operator later adds a hypothetical ``codex``).
|
|
"""
|
|
|
|
env = env if env is not None else os.environ
|
|
detected = detect_local_agent_providers(config)
|
|
if not detected:
|
|
return False
|
|
|
|
ack_via_env = _truthy_env(env.get(ACK_ENV_VAR))
|
|
if ack_flag or ack_via_env:
|
|
new_set = _interfaces_in(detected)
|
|
merged = write_acknowledgement(new_set, ack_path=ack_path)
|
|
ack_csv = ", ".join(sorted(new_set))
|
|
console.print(
|
|
f"[green]✓[/green] Acknowledged local-agent provider(s): "
|
|
f"[bold]{ack_csv}[/bold] [dim]→ {ack_path}[/dim]"
|
|
)
|
|
return False
|
|
|
|
acknowledged = load_acknowledged_interfaces(ack_path)
|
|
pending = [p for p in detected if p.interface not in acknowledged]
|
|
if not pending:
|
|
ack_csv = ", ".join(sorted(_interfaces_in(detected)))
|
|
console.print(
|
|
f"[dim]Local-agent providers acknowledged: {ack_csv}. "
|
|
f"Remove {ack_path} to undo.[/dim]"
|
|
)
|
|
return False
|
|
|
|
_render_panel(console, pending)
|
|
return True
|
|
|
|
|
|
__all__ = [
|
|
"ACK_ENV_VAR",
|
|
"ACK_FILE_PATH",
|
|
"DOCS_LEARN_MORE",
|
|
"DOCS_RELATIVE_PATH",
|
|
"LOCAL_AGENT_PROVIDER_INTERFACES",
|
|
"LocalAgentProvider",
|
|
"detect_local_agent_providers",
|
|
"load_acknowledged_interfaces",
|
|
"maybe_warn_local_agent_providers",
|
|
"write_acknowledgement",
|
|
]
|