← Indice documentazione Microprogettazione › policy

myclaw

policy — il decisore centrale
Microprogettazione v1.0 — 22 aprile 2026
Primo documento di fase 2.
Reifica la 5ª Legge (omeostasi) proposta nel giudizio di fase 0.

Pubblico: chi implementerà la logica di autorizzazione. Lettura: 22 min.

Indice

  1. Scopo: la Policy come autorità operativa unica
  2. I tre livelli di autonomy, formalizzati
  3. L'albero di decisione
  4. Rate limit: per sender, per tool, per trace
  5. Cost caps: la 5ª Legge operativa
  6. Cost tiering: quale modello per quale decisione
  7. Forbidden paths: integrazione con sandbox
  8. Batching e never-list persistenti
  9. Contratto Python
  10. Alternative considerate
  11. Test di conformità
  12. Riferimenti

1. Scopo: la Policy come autorità operativa unica

La Policy risponde a una sola domanda per ogni tool call: "è lecito?". Con tre possibili risposte: allow, deny, approve_required. Nessun altro componente prende questa decisione. Il runtime la consulta, la sandbox la esegue, la channel la recapita — ma il verdetto nasce qui.

Cosa copre

Cosa non copre

Tipi condivisi. Gli oggetti PlannedAction, PolicyDecision, ViolationReport, TrustScore sono definiti nel doc tipi (o nel proprio owner-doc, vedi tabella §3 di quello). Qui li usiamo senza ridefinirli.

2. I tre livelli di autonomy, formalizzati

I livelli sono stati introdotti narrativamente nell'Architettura intro §6. Qui diventano tabella operativa che la Policy legge come fonte di verità.

LivelloDefault tool espostiDefault verdetto per side-effectOverride possibile
ReadOnly Solo tool con has_side_effects=False. Neanche fs_write in workspace. Non applicabile (non esposto). No. Un'azione con side effect → deny, nessuna opzione approve.
Supervised
default
Tutti, incluso fs_write (ma ristretto a workspace senza chiedere). approve_required se fuori dal "green zone" del livello. Sì, via batching (classe effetto per 10 min) o never-list.
Full Tutti, senza filtro se non forbidden. allow per quasi tutto. Approve richiesto solo per azioni "forbidden-adjacent" (es. shell_run con pattern sospetto). No. Full è già il livello più permissivo; non si scende tramite batching, si sale tramite sessione esplicita con scadenza.

Green zone per autonomy

Un'azione è "in green zone" se soddisfa tutte:

Green → allow. Yellow → approve_required. Red → deny.

3. L'albero di decisione

Check 0 — Preflight costituzionale

Prima dei cinque check "operativi" sotto, la Policy esegue un check zero-th contro la Costituzione. È una chiamata sincrona, deterministica, senza LLM. Dal punto di vista architetturale questo check è l'invocazione della fase Guardia del Vaglio: la Policy delega a Vaglio.evaluate(action, flow="reactive"), che restituisce un ConstitutionalVerdict. Per il flusso reattivo, il Giudice teleologico non viene invocato (l'utente ha chiesto; il Telos non valuta richieste esplicite).

viol = constitution.check(PlannedAction.from_tool_call(tool_call, sender, trace, autonomy))
if viol is not None:
    if viol.law in ("law.0", "law.1"):
        return PolicyDecision(verdict="deny", via="forbidden",
                              reason=viol.reason, effect_class=...)
    if viol.law == "law.2":
        # Legge 2 "non ingannare": generalmente deny, ma il profilo di
        # rigidità può permettere approve_required per violazioni borderline.
        return PolicyDecision(verdict="deny", via="forbidden",
                              reason=viol.reason, effect_class=...)
    # law.3 (tracciabilità): non blocca, ma è materiale per observability
# continua con Check 1..6
DECISIONE v1 (precedenza delle Leggi): Legge 0 > 1 > 2 > 3 (come definito in constitution §3). Una violazione delle Leggi 0, 1, 2 produce sempre deny. La Legge 3 (tracciabilità) non blocca mai, ma genera un evento law3_trace_gap in observability. L'ordine di precedenza è pre-computato: la Policy non delega all'LLM la risoluzione di conflitti fra leggi.
tool_call + trace + sender da agent_runtime §4 1. forbidden path? (sandbox §8) 2. never-list match? (sender × effect_class) 3. rate limit exceeded? (§4) 4. budget hit? (soft/hard, §5) 5. green zone per autonomy? (§2 + batch check) deny · "forbidden" deny · "never list" deny · "rate limited" deny · "budget hit" allow green zone / batch match approve_required yellow zone → broker Ordine dei check: dal più restrittivo al più permissivo. Short-circuit al primo deny. O(1) per ogni check.
Figura 1 — L'albero di decisione della Policy. Cinque check in ordine, short-circuit al primo deny. Al quinto, se green zone → allow diretto, altrimenti approve_required che rimbalza al broker (approval_ux).
DECISIONE v1: ordine dei check come in figura. Motivazione: i forbidden paths sono il vincolo più duro (anche Full non passa), quindi vanno per primi. Il never-list per sender è personalizzazione esplicita del singolo utente. Rate/budget sono controlli di risorsa. La green zone è l'ultimo filtro — il più sofisticato, ma inutile se i precedenti l'hanno già escluso.

Check 6 — Trust-gating (promozione approve_required → allow)

Dopo i cinque check deterministici, se il verdetto corrente è approve_required, la Policy applica un check non strutturale che guarda la storia: se la coppia (neuron, effect_class) ha un TrustScore sopra soglia e l'azione è a basso rischio, la Policy promuove il verdetto ad allow (salvo override di autonomy). Questo è il meccanismo di adattamento darwiniano che rl_offline definisce; la Policy ne è la sola utilizzatrice runtime.

trust = trust_store.get(subject=(tool_name, effect_class))
# TrustScore è definito in types.html §4.2
if verdict == "approve_required" and action.risk == "low":
    threshold = TRUST_THRESHOLDS[autonomy][effect_class]  # da config
    if trust is not None and trust.value >= threshold and trust.decayed_ok():
        return PolicyDecision(
            verdict="allow", via="trust_gated",
            reason=f"trust={trust.value:.2f} ≥ {threshold:.2f}",
            trust_snapshot=trust,
        )
# altrimenti il verdetto resta approve_required
Vincoli di sicurezza. Il trust-gating non promuove mai: (a) azioni con risk == "high"; (b) azioni su autonomy read_only; (c) azioni con effect_class in never-promote list (es. cost_spend sopra 0.10€, self_modify, neuron_spawn). Il default è conservativo: solo effect class esplicitamente in allow-list sono candidate alla promozione.

Il TrustStore è persistente e aggiornato off-line da rl_offline (trace-scoring). La Policy lo legge soltanto a runtime: non scrive, non calcola, non aggiorna.

4. Rate limit: per sender, per tool, per trace

Tre assi di rate limiting, applicati a ogni tool call, indipendenti:

AsseFinestraLimite defaultMotivo
Per sender 1 minuto 30 tool call / min Proteggere da loop accidentali a ogni livello.
Per sender 1 ora 400 tool call / h Limite aggregato per uso ragionevole.
Per tool 1 minuto dipende dal tool (schema.rate_limit_min) Es. web_fetch: max 20/min per gentile con target. fs_read: 100/min.
Per trace intera trace max 10 tool call / trace È max_steps del runtime. Se la trace supera, outcome timeout_steps.
Per effect_class 1 ora 50 / h (configurabile) Protezione aggiuntiva per classi di azione costose o esterne.

I contatori sono tenuti in RAM con sliding window + backup periodico su SQLite (workspace/state/rate_limits.db) per sopravvivere ai restart.

5. Cost caps: la 5ª Legge operativa

La 5ª Legge dell'omeostasi (proposta nel giudizio di fase 0, adattamento #3): "myclaw non deve divergere per consumo illimitato". Qui la reifichiamo in numeri concreti.

CapValore defaultCosa succede al raggiungimento
Soft daily 2 € Warning utente via canale ("ho speso 2€ oggi"). Force routing su local-fast per i Thought del reasoning loop (non solo per gate). Frontier chiamabile esplicitamente da utente.
Hard daily 5 € Policy deny su qualunque supra_llm frontier. Mode read-only fino a mezzanotte (TZ casa). Cron jobs pendenti marcati skipped_budget.
Per-trace 0.30 € Single trace che sfora → outcome budget_hit (definito in agent_runtime §2).
Per-hour rolling 1 € / 60 min Previene burst costosi concentrati (es. loop accidentali). Stesso effetto del soft daily se hit.

I contatori vivono in workspace/.audit/budget.json, aggiornati da ogni ThoughtStep.cost_usd della ExecutionTrace. Reset: a mezzanotte locale.

Override utente

Roberto può alzare temporaneamente il soft/hard cap con:

myclaw budget raise --soft 5 --hard 10 --for 24h --reason "sto facendo ricerca intensiva"

L'override scade da solo, è loggato, e viene mostrato nell'/admin/budget come elemento evidente ("budget elevato, torna al default il 2026-04-23 alle 10:00").

6. Cost tiering: quale modello per quale decisione

Il tiering è il pezzo della Policy che instrada ogni chiamata LLM verso il tier appropriato. Ogni chiamata LLM non è uguale alle altre: ragionare su un log richiede un frontier; decidere se "fs_write è permesso" non richiede nemmeno un LLM forte.

Richiesta LLM da runtime o tool (thought | critic | classify | summarize) Tier router valuta purpose + budget + scenario + autonomy tabella sotto local-fast llama.cpp 8B o equivalente latenza < 500ms · costo 0 frontier Claude Sonnet/Opus via supra latenza 1-3s · ~0.02-0.05€/turno purpose=classify|gate|compress purpose=reason|respond|summarize-rich suprastructure registry.get(LLMProvider, tier) già fa failover tra provider Budget override soft cap → forza local-fast hard cap → tutto bloccato override il router Default ratio atteso: 40% local-fast, 60% frontier. Con budget over il soft: 80/20.
Figura 2 — Tier router. Ogni chiamata LLM ha un purpose dichiarato dal caller; il router sceglie il tier in base a purpose + budget corrente + autonomy. Il budget override forza downgrade quando il soft cap è stato raggiunto.

Tabella di routing

PurposeTier defaultOverride se budget softEsempio
classify local-fast local-fast (invariato) "Questo messaggio è una domanda, un comando, o chitchat?"
gate local-fast local-fast "Il contenuto di questo file contiene credenziali? Sì/No"
compress local-fast local-fast "Riassumi questi 20 turni in 300 token"
reason (Thought del loop) frontier local-fast (downgrade) Il reasoning loop principale di agent_runtime
respond (final_response) frontier frontier (respond è la voce, non si compromette) La risposta finale all'utente
summarize-rich frontier local-fast "Riassumi questo articolo lungo con sfumature"
critic frontier local-fast (degrada qualità del giudizio, loggato come warning) Il critic LLM dell'eval harness (§5 di eval.html)
neuron-synthesis frontier (sempre) no synthesis possibile oltre soft cap La pipeline di sintesi neuroni esige un modello forte. Se soft cap hit, synthesis bloccata.

Risparmio atteso: con una distribuzione realistica 40% local-fast / 60% frontier, il costo giornaliero tipico scende da ~3€ a ~1.20€ (60% risparmio). Nel giudizio di fase 0 ho stimato 60-80% di risparmio potenziale: allineato.

7. Forbidden paths: integrazione con sandbox

La lista canonica dei forbidden paths vive in src/myclaw/sandbox/forbidden.py (vedi sandbox §8). La Policy non duplica la lista: la importa e la interroga.

from myclaw.sandbox.forbidden import FORBIDDEN_PATHS, is_forbidden

# Nella decisione della Policy:
def _check_forbidden(tool_name: str, args: dict) -> bool:
    for path_arg in _extract_path_args(tool_name, args):
        if is_forbidden(path_arg):
            return True
    return False

_extract_path_args sa, per ogni tool, quali argomenti sono path (es. fs_read.path, fs_write.path, shell_run.cwd). is_forbidden applica Path.resolve() prima del confronto (previene path traversal come workspace/../src).

Il check forbidden è il primo. Sempre. Anche se rate limit e budget fossero disponibili, un path forbidden taglia la decisione alla radice. Nessun override lo aggira. La modifica della lista richiede il "rito" (sandbox §8): edit codice + restart + audit log dedicato.

8. Batching e never-list persistenti

La Policy interroga l'ApprovalBroker (approval_ux §9) per due pezzi:

  1. active_batches(sender): elenco di BatchGrant attivi. Se la tool call matcha un batch → verdetto allow immediato (con flag via="batch" nella trace).
  2. never_list(sender): elenco di effect_class blacklisted. Match → deny (check #2 in Figura 1).

Questi sono stato del broker. La Policy legge, non modifica. Il broker li aggiorna quando l'utente preme "approva per 10 min" o "mai più chiederlo" (approval_ux §4).

Persistenza

workspace/state/approvals.db           # SQLite
  └─ batches(batch_id, sender, effect_class, created_at, expires_at)
  └─ never_list(sender, effect_class, created_at, reason)

never_list non scade. È una decisione permanente di Roberto. Si rimuove solo via myclaw approvals revoke-never <effect_class>.

9. Contratto Python

from typing import Protocol, Literal
from dataclasses import dataclass

@dataclass
class PolicyDecision:
    verdict: Literal["allow", "deny", "approve_required"]
    reason: str                      # breve, user-facing se deny/approve
    effect_class: str                # ricavata dagli args (per batching)
    via: Literal["direct", "batch", "never_list", "budget", "rate",
                 "forbidden", "green_zone",
                 "constitution", "trust_promoted"] = "direct"
    tier_suggestion: Literal["local-fast", "frontier"] = "frontier"
    # Se verdict=="approve_required", draft per il gateway (vedi gateway §4.1):
    approval_draft: "ApprovalRequestDraft | None" = None
    # Se via=="trust_promoted", copia dello score consultato (audit):
    trust_snapshot: "TrustScore | None" = None

@dataclass
class ApprovalRequestDraft:
    """Pre-dato dalla Policy al Gateway. Il Gateway lo trasforma in
    ApprovalRequest completo (aggiunge request_id, timeout, etc.) e chiama
    ApprovalBroker.request(). Vedi gateway.html §4.1 per il flusso."""
    effect_class: str
    tool_name: str
    args_preview: dict            # redatta (no segreti)
    summary: str                  # one-line human; preferibilmente da tool.dry_run()
    reversibility: Literal["reversible", "reversible_with_cost", "irreversible"]
    risk: Literal["medium", "high"]
    violation_hint: "ViolationReport | None" = None

class Policy(Protocol):
    async def evaluate(
        self,
        tool_call: dict,
        trace: ExecutionTrace,
        sender: str,
        autonomy: str,
    ) -> PolicyDecision:
        """
        Decide in O(1) il verdetto per un tool call.

        Sequenza interna (vedi §3):
          0. Costruisce PlannedAction (types §4).
          1. Consulta Constitution.check(action):
             - violazione law.0/1/2 → deny immediato (via="constitution").
             - violazione law.3      → non blocca, emette evento osservabilità.
          2. Esegue i 5 check operativi (forbidden, never_list, rate, budget,
             green_zone).
          3. Se arriva a green_zone e l'azione è side-effect:
             legge TrustScore(subject=(tool_name, effect_class)); se
             supera TrustThreshold → allow con via="trust_promoted".
             Altrimenti approve_required con approval_draft popolato.
        """
        ...

    async def pick_llm_tier(
        self,
        purpose: Literal["classify", "gate", "compress", "reason",
                         "respond", "summarize-rich", "critic", "neuron-synthesis"],
        trace: ExecutionTrace | None = None,
    ) -> Literal["local-fast", "frontier"]:
        """Cost-tiering router (§6)."""
        ...

    async def check_budget(self) -> BudgetStatus:
        """Lettura dello stato budget corrente."""
        ...

@dataclass
class BudgetStatus:
    spent_today_eur: float
    soft_cap_eur: float
    hard_cap_eur: float
    soft_hit: bool
    hard_hit: bool
    rolling_hour_eur: float
    per_hour_cap_eur: float
    override_active: bool
    override_expires_at: datetime | None

10. Alternative considerate

AlternativaPerché scartata
Policy engine esterno (OPA, Cedar, Rego) Richiede processo separato, DSL dedicata, tooling. Per un singolo utente in casa è overkill. Preferiamo Python nativo, un file (src/myclaw/policy/engine.py).
Policy come regole YAML dichiarative Attraente ma presto diventa inespressivo. Le nostre decisioni dipendono da stato (budget corrente, rate counter, batch attivi): YAML statico non basta. Python con tabelle YAML-backed è più pragmatico.
5 livelli di autonomy invece di 3 Overhead cognitivo × 5 senza beneficio. Tre è sufficiente e mappa su un'intuizione chiara.
Budget come ore-CPU anziché euro Meno leggibile per utente. Euro è l'unità naturale per frontier API. Mantieniamo anche ore-CPU come metrica secondaria (in BudgetStatus) per monitoring.
Tier routing deciso dall'LLM stesso Ricorsione: per decidere il tier servirebbe un LLM. Il tier deve nascere da euristica deterministica. Se un giorno si vuole finezza, si addestra un classifier piccolo (punto aperto).
Nessun budget (trust the user) Gli agenti auto-evolutivi divergono per consumo, non per malizia (letteratura). Budget è parte della definizione "autonomo".

11. Test di conformità

InvarianteTest
Forbidden short-circuitTool call che tocca /etc/hosts con autonomy=Full, budget pieno, never-list vuoto → verdict=deny, via=forbidden.
Never-list applicataSender X ha blacklistato telegram_send:@tizio → ogni call matching → deny, via=never_list.
Rate limit scattaFixture con 30 tool call in 30s → 31ª → deny, via=rate.
Hard budget blocca frontierBudget.spent=5.01€, hard=5 → qualunque call con tier=frontier → deny. local-fast passa.
Soft budget downgrade tierBudget.spent=2.5€, soft=2 → pick_llm_tier("reason") ritorna local-fast.
Respond resta frontier oltre softpick_llm_tier("respond") con soft hit → frontier (non degradiamo la voce all'utente).
Neuron synthesis bloccata oltre softpick_llm_tier("neuron-synthesis") con soft hit → solleva BudgetExceededError.
Batching auto-approvaBatchGrant attivo per fs_write:~/Downloads/*, sender=X. Tool call che matcha → allow, via=batch.
Green zone Supervisedfs_write:~/opt/myclaw/workspace/x.md, autonomy=Supervised → allow, via=green_zone.
Yellow zone Supervised → approvefs_write:~/Downloads/y.pdf, autonomy=Supervised, no batch → approve_required.
Full non aggira forbiddenautonomy=Full, tool call su ~/.ssh/config → deny, via=forbidden.
Override budget trackedbudget raise crea record in .audit/, BudgetStatus.override_active=True, scade all'ora prevista.
Rate counter sopravvive restartAccumula 20 call, restart gateway, ne fai altre 11 entro 60s → 31ª rate-limited.
Decisione è O(1)Benchmark: 10000 evaluate() consecutive in < 1s (≤100µs per chiamata).

12. Riferimenti

RiferimentoCosa abbiamo preso
Sandbox §8 (forbidden paths)Non duplichiamo la lista: importiamo e interroghiamo.
Approval_ux §4 (batching) e §9 (contratto broker)Policy interroga il broker per batch e never-list.
Agent_runtime §2 (budget hit outcome)Policy è il gate che lo emette quando i cap scattano.
Giudizio di fase 0 — adattamento #3 (5ª Legge omeostasi)Reificato in §5.
Giudizio di fase 0 — adattamento #4 (tre gate enforcement)Policy è il secondo gate (dopo la Costituzione in prompt, prima dell'esecuzione sandboxata).
Giudizio di fase 0 — raccomandazione #7 (model tiering)§6 ne è la realizzazione piena.
Architettura intro §3 (strato 2 policy)Il ruolo architetturale complessivo.
Neuroni+memoria §4 (legge darwiniana, fitness)La fitness non vince mai sulla Policy: un neurone con alta fitness che tocca forbidden → ancora deny.

Continua a leggere

prossimo
workspace (prossimo)
I file markdown IDENTITY/USER/MEMORY/AGENTS/SOUL: la "personalità" che il runtime inietta in prompt.
microprogettazione · 20 min
approval_ux
Il broker che gestisce i verdetti approve_required che la Policy emette.
microprogettazione · 25 min
sandbox
I forbidden paths canonici e la matrice capability/autonomy che la Policy interroga.
indice
Torna alla landing
8 doc fatti su 17. Fase 2 in corso.

myclaw — policy microprogettazione v1.0 — 2026-04-22
Primo doc di fase 2. Prossimo: workspace.html.