Files
CASAN/packages/casan-harness/scripts/bash/loop_common.py
T
2026-07-08 19:07:35 +09:00

426 lines
15 KiB
Python

"""CASAN loop-engineering shared helpers (Plan-17, harness-owned).
Deny-by-default, fail-closed primitives shared by the loop primitives
(`loop-governor.py`, `loop-convergence.py`, `loop-gate.py`). No third-party
dependency is required to *function safely*: if PyYAML is unavailable the loop
still runs under the strictest built-in ceiling (fail-closed) — never an
"unlimited" fallback.
Distinction (Plan-17 T1 17.1/17.2):
* policy file ABSENT -> use STRICT_CEILING (evaluate normally).
* policy file PRESENT but unreadable/corrupt -> raise PolicyError (caller HALTs).
State is written under a redirectable, tenant-aware root so it never pollutes the
repo (tests set CASAN_LOOP_STATE_ROOT / CASAN_TENANT_STATE_ROOT to a tmp dir).
"""
import hashlib
import json
import os
import re
import subprocess
from datetime import datetime, timezone
# Strictest possible ceiling. Used when no policy file exists or no rule matches
# a run (deny-by-default: absence of an explicit grant means the tightest budget,
# not "infinite").
STRICT_CEILING = {
"max_steps": 3,
"max_tokens": 8000,
"max_wall_clock_sec": 120,
"max_cost_usd": 0.10,
"max_corrections_per_step": 1,
"on_exceed": "halt", # halt | escalate
}
_BUDGET_KEYS = (
"max_steps",
"max_tokens",
"max_wall_clock_sec",
"max_cost_usd",
"max_corrections_per_step",
)
# Strictest convergence thresholds (Plan-17 T2). Small windows => detect a stuck /
# oscillating loop *sooner* when no policy grants a looser window (deny-by-default).
STRICT_CONVERGENCE = {
"oscillation_repeat": 3, # N identical consecutive actions => OSCILLATING
"thrash_window": 4, # A,B,A,B... over this many steps => OSCILLATING
"no_progress_window": 3, # W steps with zero forward progress => STALLED
"on_stall": "escalate", # escalate | halt
}
_CONVERGENCE_INT_KEYS = ("oscillation_repeat", "thrash_window", "no_progress_window")
class PolicyError(Exception):
"""A policy file exists but cannot be trusted (unreadable / malformed).
Callers must treat this as fail-closed (HALT), never fall back to open."""
def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def profile() -> str:
return os.environ.get("CASAN_PROFILE", "dev").strip() or "dev"
def git_commit() -> str:
try:
r = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=project_root(),
capture_output=True,
text=True,
timeout=5,
)
if r.returncode == 0:
return r.stdout.strip()
except Exception:
pass
return "unknown"
def _tenant_id():
t = os.environ.get("CASAN_TENANT_ID", "").strip()
if not t:
return None
if not re.fullmatch(r"[A-Za-z0-9_-]+", t):
# Same fail-closed contract as control-plane-settings.py (SEC-23 MT-01).
raise PolicyError("tenant_id_invalid")
return t
def state_root() -> str:
explicit = os.environ.get("CASAN_LOOP_STATE_ROOT")
if explicit:
return explicit
tenant = _tenant_id()
if tenant:
base = os.environ.get(
"CASAN_TENANT_STATE_ROOT",
os.path.join(project_root(), ".specify/state/tenants"),
)
return os.path.join(base, tenant, "loops")
return os.path.join(project_root(), ".specify/state/loops")
def run_dir(run_id: str) -> str:
if not run_id or not re.fullmatch(r"[A-Za-z0-9._-]+", run_id):
raise PolicyError("run_id_invalid")
return os.path.join(state_root(), "runs", run_id)
def provenance(source: str, artifact_path=None, verified: bool = False) -> dict:
"""Every primitive output carries this envelope (Plan-13 §8.6 data-contract)."""
return {
"source": source,
"artifact_path": artifact_path,
"commit": git_commit(),
"run_at": now_iso(),
"verified": bool(verified),
}
def policy_path() -> str:
explicit = os.environ.get("CASAN_LOOP_POLICY_FILE")
if explicit:
return explicit
# Plan-01: loop-policy.yaml is harness config, not app state — resolve it inside the
# package (this file lives at <harness>/scripts/bash/), independent of any .specify facade.
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "config", "loop-policy.yaml"))
def load_policy():
"""Return the parsed policy dict, or None when no policy file exists.
Fail-closed: a present-but-unreadable/malformed policy raises PolicyError so
the caller HALTs rather than silently running unbounded.
"""
path = policy_path()
if not os.path.isfile(path):
return None
try:
import yaml # optional dependency
except ImportError as exc: # cannot parse a policy we were told to honour
raise PolicyError("pyyaml_unavailable") from exc
try:
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception as exc: # malformed YAML
raise PolicyError(f"policy_unreadable:{exc}") from exc
if data is None:
raise PolicyError("policy_empty")
if not isinstance(data, dict):
raise PolicyError("policy_not_mapping")
return data
def _coerce_budget(raw, base):
"""Overlay only the known, well-typed budget keys from `raw` onto `base`.
Unknown keys are ignored; a wrong-typed value fails closed."""
out = dict(base)
if not isinstance(raw, dict):
return out
for k in _BUDGET_KEYS:
if k in raw and raw[k] is not None:
v = raw[k]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0:
raise PolicyError(f"bad_budget_value:{k}")
out[k] = v
if "on_exceed" in raw and raw["on_exceed"] is not None:
oe = raw["on_exceed"]
if oe not in ("halt", "escalate"):
raise PolicyError(f"bad_on_exceed:{oe}")
out["on_exceed"] = oe
return out
# --- governed override layer (Plan-17 T5 meta-loop) -------------------------
# A versioned, approved change recorded in the control-plane settings store can
# tighten/loosen the effective ceiling. This is how a governed meta-loop decision
# actually changes the governor's behaviour (not just a proposal on paper).
_GOV_BUDGET_MAP = {
"loop.max_steps": "max_steps",
"loop.max_tokens": "max_tokens",
"loop.max_wall_clock_sec": "max_wall_clock_sec",
"loop.max_cost_usd": "max_cost_usd",
"loop.max_corrections_per_step": "max_corrections_per_step",
}
_GOV_CONV_MAP = {
"loop.oscillation_repeat": "oscillation_repeat",
"loop.no_progress_window": "no_progress_window",
}
def cp_store_path() -> str:
"""Resolve the control-plane settings store the same way control-plane-settings.py
does, so governed loop overrides are read from exactly where they were written."""
explicit = os.environ.get("CASAN_CP_STORE_FILE")
if explicit:
return explicit
tenant = _tenant_id()
if tenant:
base = os.environ.get(
"CASAN_TENANT_STATE_ROOT",
os.path.join(project_root(), ".specify/state/tenants"),
)
return os.path.join(base, tenant, "control-plane", "settings.json")
return os.path.join(project_root(), ".specify/level5/control-plane-settings.json")
def load_governed_overrides() -> dict:
"""Best-effort read of `loop.*` governed settings. Absent/unreadable store =>
{} (no override => the YAML/strict ceiling stands, which is already safe, so a
read failure never *loosens* anything)."""
path = cp_store_path()
if not os.path.isfile(path):
return {}
try:
data = json.load(open(path, encoding="utf-8"))
settings = data.get("settings", {})
out = {}
for k, v in settings.items():
if k.startswith("loop.") and isinstance(v, dict) and "value" in v:
out[k] = v["value"]
return out
except (OSError, ValueError, KeyError, TypeError):
return {}
def org_ceiling(policy) -> dict:
"""Organization hard cap (17.19): a governed loosen can never exceed these,
even with approval. Malformed => fail-closed (PolicyError via _coerce_budget)."""
if not policy:
return {}
oc = policy.get("org_ceiling")
if not isinstance(oc, dict):
return {}
return _coerce_budget(oc, {})
def _apply_governed_budget(ceiling, policy):
overrides = load_governed_overrides()
if not overrides:
return ceiling, ""
oc = org_ceiling(policy)
applied = []
for gk, bk in _GOV_BUDGET_MAP.items():
v = overrides.get(gk)
if isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0:
if bk in oc:
v = min(v, oc[bk]) # clamp to org hard cap (defense-in-depth)
ceiling[bk] = v
applied.append(bk)
return ceiling, ("+governed(" + ",".join(applied) + ")" if applied else "")
def _apply_governed_convergence(cfg):
overrides = load_governed_overrides()
if not overrides:
return cfg, ""
applied = []
for gk, ck in _GOV_CONV_MAP.items():
v = overrides.get(gk)
if isinstance(v, int) and not isinstance(v, bool) and v >= 1:
cfg[ck] = v
applied.append(ck)
return cfg, ("+governed(" + ",".join(applied) + ")" if applied else "")
def resolve_budget(policy, prof: str, delegation_level=None, project=None):
"""Compute the effective ceiling for a run.
Deny-by-default: start from STRICT_CEILING and overlay, in order,
profile.defaults -> delegation_levels[L] -> projects[name] -> governed
overrides (clamped to org_ceiling). Any field not granted stays strictest.
Returns (ceiling_dict, source_tag).
"""
ceiling = dict(STRICT_CEILING)
sources = []
if policy:
profiles = policy.get("profiles")
if not isinstance(profiles, dict):
raise PolicyError("policy_missing_profiles")
prof_block = profiles.get(prof)
if isinstance(prof_block, dict):
defaults = prof_block.get("defaults")
if isinstance(defaults, dict):
ceiling = _coerce_budget(defaults, ceiling)
sources.append(f"{prof}.defaults")
if delegation_level:
levels = prof_block.get("delegation_levels")
if isinstance(levels, dict) and delegation_level in levels:
ceiling = _coerce_budget(levels[delegation_level], ceiling)
sources.append(f"delegation:{delegation_level}")
if project:
projects = prof_block.get("projects")
if isinstance(projects, dict) and project in projects:
ceiling = _coerce_budget(projects[project], ceiling)
sources.append(f"project:{project}")
else:
# Unknown profile => no matching rule => strictest (deny-by-default).
sources.append(f"strict-default(no-profile:{prof})")
else:
sources.append("strict-default(no-policy)")
ceiling, gov = _apply_governed_budget(ceiling, policy)
tag = "+".join(sources) if sources else f"strict-default(empty:{prof})"
return ceiling, tag + gov
def _coerce_convergence(raw, base):
out = dict(base)
if not isinstance(raw, dict):
return out
for k in _CONVERGENCE_INT_KEYS:
if k in raw and raw[k] is not None:
v = raw[k]
if not isinstance(v, int) or isinstance(v, bool) or v < 1:
raise PolicyError(f"bad_convergence_value:{k}")
out[k] = v
if "on_stall" in raw and raw["on_stall"] is not None:
os_ = raw["on_stall"]
if os_ not in ("halt", "escalate"):
raise PolicyError(f"bad_on_stall:{os_}")
out["on_stall"] = os_
return out
def resolve_convergence(policy, prof: str):
"""Effective convergence thresholds for a profile. Deny-by-default: absent
policy / profile => strictest (detect stalls soonest). A governed override
layer (meta-loop) can adjust the windows. Returns (dict, tag)."""
cfg = dict(STRICT_CONVERGENCE)
if policy:
profiles = policy.get("profiles")
if not isinstance(profiles, dict):
raise PolicyError("policy_missing_profiles")
prof_block = profiles.get(prof)
if isinstance(prof_block, dict):
raw = prof_block.get("convergence")
if isinstance(raw, dict):
cfg = _coerce_convergence(raw, cfg)
src = f"{prof}.convergence"
else:
src = f"strict-default(no-convergence:{prof})"
else:
src = f"strict-default(no-profile:{prof})"
else:
src = "strict-default(no-policy)"
cfg, gov = _apply_governed_convergence(cfg)
return cfg, src + gov
# ---------------------------------------------------------------------------
# Hash-linked loop audit log (self-contained tamper-evidence). Shares the same
# canonical-JSON + SHA-256 scheme as control-plane-settings.py so a future
# unified verifier (Plan-17 T4 / sync-point S2) can adopt it unchanged.
# ---------------------------------------------------------------------------
GENESIS_HASH = "0" * 64
def _canon(entry) -> str:
return json.dumps(entry, sort_keys=True, ensure_ascii=False)
def _hash_entry(entry) -> str:
return hashlib.sha256(_canon(entry).encode("utf-8")).hexdigest()
# Public serializer/verifier (sync-point S2: one serializer, one verifier). The
# per-run loop trace (Track 4) reuses these so its hash-chain is byte-compatible
# with the audit log and any future unified verifier.
def canonical(entry) -> str:
return _canon(entry)
def chain_hash(entry) -> str:
return _hash_entry(entry)
def audit_log_path() -> str:
return os.path.join(state_root(), "audit", "loop-audit.jsonl")
def append_audit(event: dict) -> dict:
"""Append a hash-linked audit record. Append-only; each record chains to the
previous via prev_hash so any later edit breaks the chain."""
path = audit_log_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
prev = GENESIS_HASH
seq = 0
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
last = None
for line in fh:
line = line.strip()
if line:
last = line
seq += 1
if last:
try:
prev = json.loads(last).get("hash", GENESIS_HASH)
except ValueError:
prev = GENESIS_HASH
base = {
"seq": seq,
"ts": now_iso(),
"prev_hash": prev,
"event": event,
}
base["hash"] = _hash_entry(base)
with open(path, "a", encoding="utf-8") as fh:
fh.write(_canon(base) + "\n")
return base