policy — il decisore centraleLa 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.
Constitution.check(PlannedAction) e, se riporta violazione di Legge 0–2, short-circuita a deny (vedi §3 Check 0).approve_required → allow basata su TrustScore per azioni a basso rischio con storia positiva (vedi §3 Check 6 e types §4).ApprovalRequestDraft minimo; la costruzione finale della ApprovalRequest e la sua consegna al broker sono del gateway (vedi gateway §4.1).pairing.html).constitution.html). La Policy le applica via Constitution.check(); la Costituzione le enuncia.PlannedAction, PolicyDecision, ViolationReport, TrustScore sono definiti nel doc tipi (o nel proprio owner-doc, vedi tabella §3 di quello). Qui li usiamo senza ridefinirli.
I livelli sono stati introdotti narrativamente nell'Architettura intro §6. Qui diventano tabella operativa che la Policy legge come fonte di verità.
| Livello | Default tool esposti | Default verdetto per side-effect | Override 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. |
Un'azione è "in green zone" se soddisfa tutte:
fs_write:~/opt/myclaw/workspace/* è green per Supervised; fs_write:~/Downloads/* è yellow, fs_write:/etc/* è red forbidden).Green → allow. Yellow → approve_required. Red → deny.
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
law3_trace_gap in
observability. L'ordine di precedenza è pre-computato:
la Policy non delega all'LLM la risoluzione di conflitti fra leggi.
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
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.
Tre assi di rate limiting, applicati a ogni tool call, indipendenti:
| Asse | Finestra | Limite default | Motivo |
|---|---|---|---|
| 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.
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.
| Cap | Valore default | Cosa 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.
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").
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.
| Purpose | Tier default | Override se budget soft | Esempio |
|---|---|---|---|
| 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.
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).
La Policy interroga l'ApprovalBroker (approval_ux §9)
per due pezzi:
BatchGrant attivi. Se la tool call matcha un batch → verdetto allow immediato (con flag via="batch" nella trace).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).
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>.
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
| Alternativa | Perché 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". |
| Invariante | Test |
|---|---|
| Forbidden short-circuit | Tool call che tocca /etc/hosts con autonomy=Full, budget pieno, never-list vuoto → verdict=deny, via=forbidden. |
| Never-list applicata | Sender X ha blacklistato telegram_send:@tizio → ogni call matching → deny, via=never_list. |
| Rate limit scatta | Fixture con 30 tool call in 30s → 31ª → deny, via=rate. |
| Hard budget blocca frontier | Budget.spent=5.01€, hard=5 → qualunque call con tier=frontier → deny. local-fast passa. |
| Soft budget downgrade tier | Budget.spent=2.5€, soft=2 → pick_llm_tier("reason") ritorna local-fast. |
| Respond resta frontier oltre soft | pick_llm_tier("respond") con soft hit → frontier (non degradiamo la voce all'utente). |
| Neuron synthesis bloccata oltre soft | pick_llm_tier("neuron-synthesis") con soft hit → solleva BudgetExceededError. |
| Batching auto-approva | BatchGrant attivo per fs_write:~/Downloads/*, sender=X. Tool call che matcha → allow, via=batch. |
| Green zone Supervised | fs_write:~/opt/myclaw/workspace/x.md, autonomy=Supervised → allow, via=green_zone. |
| Yellow zone Supervised → approve | fs_write:~/Downloads/y.pdf, autonomy=Supervised, no batch → approve_required. |
| Full non aggira forbidden | autonomy=Full, tool call su ~/.ssh/config → deny, via=forbidden. |
| Override budget tracked | budget raise crea record in .audit/, BudgetStatus.override_active=True, scade all'ora prevista. |
| Rate counter sopravvive restart | Accumula 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). |
| Riferimento | Cosa 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. |
myclaw — policy microprogettazione v1.0 — 2026-04-22
Primo doc di fase 2. Prossimo: workspace.html.