234 lines
9.5 KiB
Python
234 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""CASAN Meta-loop — self-improving loop policy (Plan-17 Track 5, harness-owned).
|
|
|
|
The loop that improves the loop. It reads a loop trace (and optional AgentOps
|
|
metrics) and PROPOSES loop-policy changes (dry-run, never writes). Applying a
|
|
proposal is governed exactly like every other security-sensitive change:
|
|
* proposal != application (17.17): `propose` only prints JSON,
|
|
* apply needs a real approval + Separation of Duties (proposer != approver) (17.18),
|
|
* apply routes through control-plane-settings.py so the change is versioned,
|
|
audited (hash-chain) and rollback-able,
|
|
* a loosen proposal that exceeds the org hard cap is refused (17.19) — the
|
|
meta-loop cannot grant itself unbounded budget.
|
|
|
|
Subcommands:
|
|
propose --loop-trace R|FILE [--agentops JSONL] [--profile prod|dev]
|
|
apply --proposals FILE --id ID --proposer P --approver A [--approval TOK]
|
|
[--allow-untrusted]
|
|
|
|
Exit codes:
|
|
0 proposed / applied ok
|
|
3 DENY (SoD violation, missing approval, exceeds org ceiling, untrusted, governed-set failed)
|
|
1 unreadable proposals / unknown id
|
|
2 usage error
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import loop_common as lc
|
|
|
|
|
|
def _emit(payload):
|
|
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
def _load_trace(ref):
|
|
"""Accept either a run-id (resolved to its trace.jsonl) or a direct file path."""
|
|
path = ref
|
|
if not os.path.isfile(path):
|
|
candidate = os.path.join(lc.run_dir(ref), "trace.jsonl")
|
|
if os.path.isfile(candidate):
|
|
path = candidate
|
|
else:
|
|
raise lc.PolicyError("trace_not_found")
|
|
rows = []
|
|
with open(path, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rows.append(json.loads(line))
|
|
except ValueError:
|
|
raise lc.PolicyError("trace_corrupt")
|
|
# entries are {seq, prev_hash, iteration, hash}; return the iterations
|
|
return [r.get("iteration", r) for r in rows]
|
|
|
|
|
|
def build_proposals(iterations, agentops):
|
|
proposals = []
|
|
steps = [it.get("step", 0) for it in iterations if isinstance(it.get("step"), int)]
|
|
max_step = max(steps) if steps else 0
|
|
decisions = [str(it.get("decision") or "") for it in iterations]
|
|
verdicts = [str(it.get("gate_verdict") or "") for it in iterations]
|
|
progresses = [it.get("progress") for it in iterations if isinstance(it.get("progress"), (int, float))]
|
|
|
|
# (a) The loop repeatedly hit its budget ceiling -> propose loosening max_steps.
|
|
# Loosening is security-sensitive and is capped by org_ceiling at apply time.
|
|
if any(d == "HALT" for d in decisions):
|
|
proposals.append({
|
|
"id": "P-LOOSEN-STEPS",
|
|
"key": "loop.max_steps",
|
|
"value": max_step + 10,
|
|
"direction": "loosen",
|
|
"security_sensitive": True,
|
|
"reason": f"run halted on budget at step {max_step}; propose raising max_steps to {max_step + 10}",
|
|
})
|
|
|
|
# (b) Oscillation/thrash seen -> propose TIGHTENING oscillation_repeat (detect sooner).
|
|
# Tightening is safe but still governed (propose != apply).
|
|
if any(v == "OSCILLATING" for v in verdicts) or (agentops or {}).get("oscillations"):
|
|
proposals.append({
|
|
"id": "P-TIGHTEN-OSC",
|
|
"key": "loop.oscillation_repeat",
|
|
"value": 2,
|
|
"direction": "tighten",
|
|
"security_sensitive": False,
|
|
"reason": "oscillation observed; tighten oscillation_repeat to 2 to break loops sooner",
|
|
})
|
|
|
|
# (c) Progress stagnated across the trace -> propose tightening the no-progress window.
|
|
if len(progresses) >= 3 and max(progresses) <= min(progresses):
|
|
proposals.append({
|
|
"id": "P-TIGHTEN-NOPROG",
|
|
"key": "loop.no_progress_window",
|
|
"value": 2,
|
|
"direction": "tighten",
|
|
"security_sensitive": False,
|
|
"reason": "no forward progress across the trace; tighten no_progress_window to 2",
|
|
})
|
|
return proposals
|
|
|
|
|
|
def cmd_propose(args):
|
|
iterations = _load_trace(args.loop_trace)
|
|
agentops = None
|
|
if args.agentops and os.path.isfile(args.agentops):
|
|
try:
|
|
agentops = json.load(open(args.agentops, encoding="utf-8"))
|
|
except ValueError:
|
|
agentops = None
|
|
# AgentOps/loop-trace here is local, unsigned telemetry -> untrusted by default
|
|
# (ARCH-08 parity with self-improve). Enforced apply of an untrusted proposal
|
|
# requires --allow-untrusted.
|
|
proposals = build_proposals(iterations, agentops)
|
|
for p in proposals:
|
|
p["source_trust"] = "untrusted"
|
|
_emit({"proposals": proposals, "count": len(proposals), "source_trust": "untrusted"})
|
|
return 0
|
|
|
|
|
|
def cmd_apply(args):
|
|
try:
|
|
data = json.load(open(args.proposals, encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
_emit({"decision": "DENY", "reason": "proposals_unreadable", "proposals": args.proposals})
|
|
return 1
|
|
proposal = next((p for p in data.get("proposals", []) if p.get("id") == args.id), None)
|
|
if proposal is None:
|
|
_emit({"decision": "DENY", "reason": "unknown_proposal", "id": args.id})
|
|
return 1
|
|
|
|
# Separation of Duties (17.18): the proposer can never be their own approver.
|
|
if not args.proposer or not args.approver or args.proposer == args.approver:
|
|
_emit({"decision": "DENY", "reason": "sod_violation",
|
|
"proposer": args.proposer, "approver": args.approver})
|
|
return 3
|
|
|
|
# Applying ALWAYS requires an approval (propose != apply).
|
|
if not (args.approval or "").strip():
|
|
_emit({"decision": "DENY", "reason": "approval_required", "id": args.id})
|
|
return 3
|
|
|
|
# ARCH-08: refuse untrusted telemetry-derived proposals in enforced mode.
|
|
enforced = (os.environ.get("CASAN_PROFILE") == "prod"
|
|
or os.environ.get("CASAN_METALOOP_STRICT") == "1")
|
|
if proposal.get("source_trust", "untrusted") == "untrusted" and enforced and not args.allow_untrusted:
|
|
_emit({"decision": "DENY", "reason": "untrusted_source", "id": args.id})
|
|
return 3
|
|
|
|
key = proposal.get("key")
|
|
value = proposal.get("value")
|
|
if key not in lc._GOV_BUDGET_MAP and key not in lc._GOV_CONV_MAP:
|
|
_emit({"decision": "DENY", "reason": "key_not_governable", "key": key})
|
|
return 3
|
|
|
|
# Org hard cap (17.19): a loosen can never exceed the org ceiling.
|
|
if proposal.get("direction") == "loosen" and key in lc._GOV_BUDGET_MAP:
|
|
try:
|
|
policy = lc.load_policy()
|
|
except lc.PolicyError as exc:
|
|
_emit({"decision": "DENY", "reason": "fail_closed", "detail": str(exc)})
|
|
return 3
|
|
oc = lc.org_ceiling(policy)
|
|
bk = lc._GOV_BUDGET_MAP[key]
|
|
if bk in oc and isinstance(value, (int, float)) and value > oc[bk]:
|
|
_emit({"decision": "DENY", "reason": "exceeds_org_ceiling",
|
|
"key": key, "value": value, "org_ceiling": oc[bk]})
|
|
return 3
|
|
|
|
# Governed apply: route through the control-plane store (versioned + audit +
|
|
# rollback + its own approval verification in enforced mode). CASAN_APPROVER is
|
|
# honoured by control-plane-settings.py's enforced approval check.
|
|
cps = os.path.join(os.path.dirname(__file__), "control-plane-settings.py")
|
|
env = dict(os.environ)
|
|
env["CASAN_APPROVER"] = args.approver
|
|
cmd = [
|
|
sys.executable, cps, "set", key, json.dumps(value),
|
|
"--actor", args.proposer,
|
|
"--reason", f"meta-loop {args.id} ({proposal.get('direction')})",
|
|
"--approval", args.approval,
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
if result.returncode != 0:
|
|
_emit({"decision": "DENY", "reason": "governed_set_failed",
|
|
"detail": result.stderr.strip(), "id": args.id})
|
|
return 3
|
|
lc.append_audit({"kind": "metaloop_applied", "id": args.id, "key": key,
|
|
"value": value, "proposer": args.proposer, "approver": args.approver})
|
|
_emit({"decision": "APPLIED", "id": args.id, "key": key, "value": value,
|
|
"proposer": args.proposer, "approver": args.approver})
|
|
return 0
|
|
|
|
|
|
def build_parser():
|
|
p = argparse.ArgumentParser(description="CASAN Meta-loop (Plan-17 T5)")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pr = sub.add_parser("propose", help="Propose loop-policy changes (dry-run)")
|
|
pr.add_argument("--loop-trace", required=True, help="run-id or trace.jsonl path")
|
|
pr.add_argument("--agentops", default=None)
|
|
pr.add_argument("--profile", default=None)
|
|
pr.set_defaults(func=cmd_propose)
|
|
|
|
ap = sub.add_parser("apply", help="Apply an approved proposal (governed + SoD)")
|
|
ap.add_argument("--proposals", required=True)
|
|
ap.add_argument("--id", required=True)
|
|
ap.add_argument("--proposer", required=True)
|
|
ap.add_argument("--approver", required=True)
|
|
ap.add_argument("--approval", default="")
|
|
ap.add_argument("--allow-untrusted", action="store_true")
|
|
ap.set_defaults(func=cmd_apply)
|
|
return p
|
|
|
|
|
|
def main(argv=None):
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
return args.func(args)
|
|
except lc.PolicyError as exc:
|
|
_emit({"decision": "DENY", "reason": "fail_closed", "detail": str(exc)})
|
|
return 3
|
|
except Exception as exc: # never fail open
|
|
json.dump({"decision": "DENY", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
|
sys.stderr.write("\n")
|
|
return 3
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|