feat: update plan 17
This commit is contained in:
@@ -126,6 +126,14 @@ run "phase-sec23-registry-crypt" bash "$TESTS/phase-sec23-registry-crypt-tests.s
|
||||
run "phase-sec24-supplychain" bash "$TESTS/phase-sec24-tests.sh"
|
||||
run "phase-sec25-attestation" bash "$TESTS/phase-sec25-tests.sh"
|
||||
|
||||
# Plan-17 loop engineering (Agentic Loop Governance) — each primitive fail-closed.
|
||||
run "phase-loop-governor" bash "$TESTS/phase-loop-governor-tests.sh"
|
||||
run "phase-loop-convergence" bash "$TESTS/phase-loop-convergence-tests.sh"
|
||||
run "phase-loop-gate" bash "$TESTS/phase-loop-gate-tests.sh"
|
||||
run "phase-loop-trace" bash "$TESTS/phase-loop-trace-tests.sh"
|
||||
run "phase-loop-metaloop" bash "$TESTS/phase-loop-metaloop-tests.sh"
|
||||
run "phase-loop-run" bash "$TESTS/phase-loop-run-tests.sh"
|
||||
|
||||
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently
|
||||
# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
|
||||
run "test-integrity" python3 "$SCRIPT_DIR/test-integrity.py" verify
|
||||
|
||||
@@ -35,6 +35,16 @@ SETTINGS_POLICY = {
|
||||
"model.primary": {"securitySensitive": False, "description": "Primary model spec (e.g. ollama:ornith:9b)"},
|
||||
"security.strict": {"securitySensitive": True, "description": "H4 fail-closed strict mode"},
|
||||
"kill_switch.global": {"securitySensitive": True, "description": "Global kill-switch engage/disengage"},
|
||||
# Plan-17 loop governance overrides (meta-loop, T5). Loosening a loop budget /
|
||||
# widening a convergence window is security-sensitive: it grants the agent more
|
||||
# autonomy, so it needs a real approval + SoD and is clamped to org_ceiling.
|
||||
"loop.max_steps": {"securitySensitive": True, "description": "Loop Governor: max steps per run"},
|
||||
"loop.max_tokens": {"securitySensitive": True, "description": "Loop Governor: max tokens per run"},
|
||||
"loop.max_wall_clock_sec": {"securitySensitive": True, "description": "Loop Governor: max wall-clock seconds per run"},
|
||||
"loop.max_cost_usd": {"securitySensitive": True, "description": "Loop Governor: max cost USD per run"},
|
||||
"loop.max_corrections_per_step": {"securitySensitive": True, "description": "Loop Governor: max corrections per step"},
|
||||
"loop.oscillation_repeat": {"securitySensitive": True, "description": "Convergence: identical-action repeats before OSCILLATING"},
|
||||
"loop.no_progress_window": {"securitySensitive": True, "description": "Convergence: no-progress window before STALLED"},
|
||||
}
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN No-progress / Oscillation Detector (Plan-17 Track 2, harness-owned).
|
||||
|
||||
Watches a run's per-step observations and decides whether the loop is CONVERGING,
|
||||
STALLED (no forward progress), or OSCILLATING (repeating/thrashing actions). A
|
||||
stalled or oscillating loop is halted or escalated (on_stall policy) instead of
|
||||
being allowed to burn budget going nowhere.
|
||||
|
||||
Detection rules (thresholds from loop-policy.yaml, else strictest built-in):
|
||||
* oscillation = the last N observations share one action_hash (stuck repeating),
|
||||
* thrash = the last `thrash_window` steps alternate between exactly 2
|
||||
action_hashes (A,B,A,B...),
|
||||
* no-progress = the last W observations made zero forward progress
|
||||
(progress never exceeded the running best).
|
||||
|
||||
Deny-by-default & fail-closed: absent/unknown policy => strictest thresholds
|
||||
(detect sooner); a corrupt policy => ESCALATE/HALT, never silent CONVERGING.
|
||||
Insufficient data (< window) => CONVERGING (the Budget Governor is the hard stop).
|
||||
|
||||
Usage:
|
||||
loop-convergence.py observe --run-id R --step N --action-hash H --progress P
|
||||
loop-convergence.py verdict --run-id R [--profile prod|dev]
|
||||
|
||||
Exit codes:
|
||||
0 CONVERGING (or observe recorded ok)
|
||||
3 HALT (STALLED/OSCILLATING with on_stall=halt, OR fail-closed error)
|
||||
4 ESCALATE (STALLED/OSCILLATING with on_stall=escalate)
|
||||
2 usage error
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import loop_common as lc
|
||||
|
||||
|
||||
def _emit(payload, stream=sys.stdout):
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def _obs_path(run_id):
|
||||
return os.path.join(lc.run_dir(run_id), "convergence.jsonl")
|
||||
|
||||
|
||||
def _load_obs(run_id):
|
||||
path = _obs_path(run_id)
|
||||
rows = []
|
||||
if os.path.isfile(path):
|
||||
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:
|
||||
# A corrupt observation stream cannot be trusted.
|
||||
raise lc.PolicyError("observation_stream_corrupt")
|
||||
rows.sort(key=lambda r: r.get("step", 0))
|
||||
return rows
|
||||
|
||||
|
||||
def cmd_observe(args):
|
||||
path = _obs_path(args.run_id)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
rec = {
|
||||
"step": args.step,
|
||||
"action_hash": args.action_hash,
|
||||
"progress": args.progress,
|
||||
"ts": lc.now_iso(),
|
||||
}
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(rec, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
_emit({"decision": "OBSERVED", "run_id": args.run_id, "step": args.step})
|
||||
return 0
|
||||
|
||||
|
||||
def _detect_oscillation(hashes, cfg):
|
||||
n = cfg["oscillation_repeat"]
|
||||
if len(hashes) >= n and len(set(hashes[-n:])) == 1:
|
||||
return {"pattern": "repeat", "action_hash": hashes[-1], "count": n}
|
||||
w = cfg["thrash_window"]
|
||||
if len(hashes) >= w:
|
||||
window = hashes[-w:]
|
||||
distinct = set(window)
|
||||
if len(distinct) == 2:
|
||||
# A,B,A,B... : every element differs from its immediate neighbour.
|
||||
if all(window[i] != window[i + 1] for i in range(len(window) - 1)):
|
||||
return {"pattern": "thrash", "actions": sorted(distinct), "window": w}
|
||||
return None
|
||||
|
||||
|
||||
def _detect_no_progress(progresses, cfg):
|
||||
w = cfg["no_progress_window"]
|
||||
if len(progresses) < w:
|
||||
return None
|
||||
# Mark each step that improved on the running best; STALLED if the last W
|
||||
# steps contain no improvement at all.
|
||||
best = None
|
||||
improved = []
|
||||
for p in progresses:
|
||||
if p is None:
|
||||
improved.append(False)
|
||||
continue
|
||||
if best is None or p > best:
|
||||
improved.append(True)
|
||||
best = p
|
||||
else:
|
||||
improved.append(False)
|
||||
if not any(improved[-w:]):
|
||||
return {"window": w, "last_progress": progresses[-1]}
|
||||
return None
|
||||
|
||||
|
||||
def cmd_verdict(args):
|
||||
prof = args.profile or lc.profile()
|
||||
try:
|
||||
policy = lc.load_policy()
|
||||
cfg, source = lc.resolve_convergence(policy, prof)
|
||||
rows = _load_obs(args.run_id)
|
||||
except lc.PolicyError as exc:
|
||||
payload = {
|
||||
"decision": "HALT",
|
||||
"reason": "fail_closed",
|
||||
"detail": str(exc),
|
||||
"run_id": args.run_id,
|
||||
"provenance": lc.provenance("loop-convergence", None, verified=False),
|
||||
}
|
||||
lc.append_audit({"kind": "convergence_halt", **payload})
|
||||
_emit(payload)
|
||||
return 3
|
||||
|
||||
hashes = [r.get("action_hash") for r in rows]
|
||||
progresses = [r.get("progress") for r in rows]
|
||||
|
||||
osc = _detect_oscillation(hashes, cfg)
|
||||
stall = None if osc else _detect_no_progress(progresses, cfg)
|
||||
|
||||
if osc or stall:
|
||||
verdict = "OSCILLATING" if osc else "STALLED"
|
||||
on_stall = cfg.get("on_stall", "escalate")
|
||||
decision = "HALT" if on_stall == "halt" else "ESCALATE"
|
||||
payload = {
|
||||
"decision": decision,
|
||||
"verdict": verdict,
|
||||
"run_id": args.run_id,
|
||||
"profile": prof,
|
||||
"policy_source": source,
|
||||
"thresholds": cfg,
|
||||
"observations": len(rows),
|
||||
"evidence": osc or stall,
|
||||
"on_stall": on_stall,
|
||||
"provenance": lc.provenance("loop-convergence", None, verified=policy is not None),
|
||||
}
|
||||
lc.append_audit({"kind": "convergence_" + verdict.lower(), **payload})
|
||||
_emit(payload)
|
||||
return 3 if decision == "HALT" else 4
|
||||
|
||||
payload = {
|
||||
"decision": "CONVERGING",
|
||||
"verdict": "CONVERGING",
|
||||
"run_id": args.run_id,
|
||||
"profile": prof,
|
||||
"policy_source": source,
|
||||
"thresholds": cfg,
|
||||
"observations": len(rows),
|
||||
"provenance": lc.provenance("loop-convergence", None, verified=policy is not None),
|
||||
}
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="CASAN Convergence Detector (Plan-17 T2)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
o = sub.add_parser("observe", help="Record a per-step observation")
|
||||
o.add_argument("--run-id", required=True)
|
||||
o.add_argument("--step", type=int, required=True)
|
||||
o.add_argument("--action-hash", required=True)
|
||||
o.add_argument("--progress", type=float, required=True)
|
||||
o.set_defaults(func=cmd_observe)
|
||||
|
||||
v = sub.add_parser("verdict", help="Classify the run: CONVERGING|STALLED|OSCILLATING")
|
||||
v.add_argument("--run-id", required=True)
|
||||
v.add_argument("--profile", default=None)
|
||||
v.set_defaults(func=cmd_verdict)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "HALT", "reason": "fail_closed", "detail": str(exc)})
|
||||
return 3
|
||||
except Exception as exc: # never fail open
|
||||
_emit({"decision": "HALT", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Per-iteration Verify Contract (Plan-17 Track 3, harness-owned).
|
||||
|
||||
Turns the existing gates into a single per-loop-iteration contract: every
|
||||
iteration must pass this before the loop may advance. It composes:
|
||||
* H4 security (security-check.sh, input mode) — prompt-injection / secret /
|
||||
exfil defense; a block => DENY (terminal, never retried),
|
||||
* H3 verify — a deterministic success-criteria check (must_contain /
|
||||
must_not_contain) so "done" is *proven*, not self-declared by the model.
|
||||
|
||||
Structured correction (Plan-17 17.10): a FAIL is retried at most
|
||||
`max_corrections_per_step` times (from loop-policy.yaml via the Budget Governor);
|
||||
exceeding that budget ESCALATES instead of retrying blindly.
|
||||
|
||||
Fail-closed (Plan-17 17.11): a missing/unreadable artifact, a gate error, or a
|
||||
security-check error resolves to FAIL/DENY — never an implicit PASS.
|
||||
|
||||
No self-declared DONE (Plan-17 17.12): `--claim-done` only yields done=true when
|
||||
declared success-criteria are present AND satisfied; a bare claim fails closed.
|
||||
|
||||
Usage:
|
||||
loop-gate.py verify --run-id R --step N --artifact PATH \
|
||||
[--success-criteria FILE] [--claim-done] \
|
||||
[--profile prod|dev] [--delegation-level L2] [--project okr]
|
||||
|
||||
Exit codes:
|
||||
0 PASS (verify passed; payload.done indicates success-criteria met + claimed)
|
||||
1 FAIL (verify failed; correction budget remains -> caller corrects & retries)
|
||||
3 DENY (H4 security block; terminal, not retried)
|
||||
4 ESCALATE (FAIL and correction budget exhausted)
|
||||
2 usage error
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import loop_common as lc
|
||||
|
||||
|
||||
def _emit(payload, stream=sys.stdout):
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def _corrections_path(run_id):
|
||||
return os.path.join(lc.run_dir(run_id), "corrections.json")
|
||||
|
||||
|
||||
def _load_corrections(run_id):
|
||||
path = _corrections_path(run_id)
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
return json.load(open(path, encoding="utf-8"))
|
||||
except ValueError:
|
||||
raise lc.PolicyError("corrections_state_corrupt")
|
||||
return {}
|
||||
|
||||
|
||||
def _bump_correction(run_id, step):
|
||||
path = _corrections_path(run_id)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
data = _load_corrections(run_id)
|
||||
key = str(step)
|
||||
data[key] = int(data.get(key, 0)) + 1
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
return data[key]
|
||||
|
||||
|
||||
def _run_h4(artifact_path):
|
||||
"""Return (blocked: bool, detail: str). Fail-closed: any non-zero exit (block,
|
||||
error, timeout) is treated as blocked."""
|
||||
gate = os.path.join(os.path.dirname(__file__), "security-check.sh")
|
||||
if not os.path.isfile(gate):
|
||||
return True, "h4_gate_missing"
|
||||
with tempfile.NamedTemporaryFile(prefix="loopgate-h4-", suffix=".out", delete=False) as tf:
|
||||
out_path = tf.name
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["bash", gate, artifact_path, out_path, "input"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return True, (r.stderr.strip() or f"h4_exit_{r.returncode}")[:400]
|
||||
return False, "clean"
|
||||
except subprocess.TimeoutExpired:
|
||||
return True, "h4_timeout"
|
||||
except Exception as exc: # never fail open
|
||||
return True, f"h4_error:{exc}"
|
||||
finally:
|
||||
try:
|
||||
os.unlink(out_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_criteria(path):
|
||||
if not path:
|
||||
return None
|
||||
if not os.path.isfile(path):
|
||||
raise lc.PolicyError("success_criteria_missing")
|
||||
try:
|
||||
data = json.load(open(path, encoding="utf-8"))
|
||||
except ValueError as exc:
|
||||
raise lc.PolicyError(f"success_criteria_unreadable:{exc}")
|
||||
if not isinstance(data, dict):
|
||||
raise lc.PolicyError("success_criteria_not_mapping")
|
||||
return data
|
||||
|
||||
|
||||
def _run_h3(text, criteria):
|
||||
"""Deterministic faithfulness/eval: verify the artifact against declared
|
||||
success-criteria. Returns (passed, hint, checked)."""
|
||||
if not criteria:
|
||||
return True, None, False
|
||||
must = criteria.get("must_contain") or []
|
||||
must_not = criteria.get("must_not_contain") or []
|
||||
missing = [m for m in must if m not in text]
|
||||
present_bad = [m for m in must_not if m in text]
|
||||
if missing or present_bad:
|
||||
hint = {"missing": missing, "forbidden_present": present_bad}
|
||||
return False, hint, True
|
||||
return True, None, True
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
prof = args.profile or lc.profile()
|
||||
try:
|
||||
policy = lc.load_policy()
|
||||
ceiling, _src = lc.resolve_budget(policy, prof, args.delegation_level, args.project)
|
||||
max_corr = int(ceiling["max_corrections_per_step"])
|
||||
criteria = _load_criteria(args.success_criteria)
|
||||
except lc.PolicyError as exc:
|
||||
payload = {
|
||||
"verdict": "FAIL", "reason": "fail_closed", "detail": str(exc),
|
||||
"run_id": args.run_id, "step": args.step,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=False),
|
||||
}
|
||||
_emit(payload)
|
||||
return 1
|
||||
|
||||
# Fail-closed: artifact must exist and be readable.
|
||||
if not os.path.isfile(args.artifact):
|
||||
payload = {
|
||||
"verdict": "FAIL", "reason": "artifact_unreadable",
|
||||
"correction_hint": "produce the artifact before verifying",
|
||||
"run_id": args.run_id, "step": args.step,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=False),
|
||||
}
|
||||
_emit(payload)
|
||||
return 1
|
||||
try:
|
||||
text = open(args.artifact, encoding="utf-8", errors="replace").read()
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"verdict": "FAIL", "reason": "artifact_read_error", "detail": str(exc),
|
||||
"run_id": args.run_id, "step": args.step,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=False),
|
||||
}
|
||||
_emit(payload)
|
||||
return 1
|
||||
|
||||
# H4 security first — a security block is terminal (DENY), never retried.
|
||||
blocked, h4_detail = _run_h4(args.artifact)
|
||||
if blocked:
|
||||
payload = {
|
||||
"verdict": "DENY", "reason": "h4_security_block", "detail": h4_detail,
|
||||
"correction_hint": "remove injection / secret / exfil content; DENY is not retryable",
|
||||
"run_id": args.run_id, "step": args.step, "profile": prof,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=policy is not None),
|
||||
}
|
||||
lc.append_audit({"kind": "gate_deny", **payload})
|
||||
_emit(payload)
|
||||
return 3
|
||||
|
||||
# H3 verify against declared success-criteria.
|
||||
h3_pass, hint, checked = _run_h3(text, criteria)
|
||||
|
||||
if not h3_pass:
|
||||
count = _bump_correction(args.run_id, args.step)
|
||||
if count > max_corr:
|
||||
payload = {
|
||||
"verdict": "ESCALATE", "reason": "correction_budget_exhausted",
|
||||
"corrections": count, "max_corrections_per_step": max_corr,
|
||||
"correction_hint": hint,
|
||||
"run_id": args.run_id, "step": args.step, "profile": prof,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=policy is not None),
|
||||
}
|
||||
lc.append_audit({"kind": "gate_escalate", **payload})
|
||||
_emit(payload)
|
||||
return 4
|
||||
payload = {
|
||||
"verdict": "FAIL", "reason": "success_criteria_unmet",
|
||||
"corrections": count, "max_corrections_per_step": max_corr,
|
||||
"correction_hint": hint,
|
||||
"run_id": args.run_id, "step": args.step, "profile": prof,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=policy is not None),
|
||||
}
|
||||
_emit(payload)
|
||||
return 1
|
||||
|
||||
# PASS. DONE only when success-criteria were actually checked AND the caller
|
||||
# claims completion — never on the model's word alone.
|
||||
done = bool(args.claim_done and checked)
|
||||
if args.claim_done and not checked:
|
||||
# Self-declared done without verifiable criteria => fail closed.
|
||||
payload = {
|
||||
"verdict": "FAIL", "reason": "unverifiable_done_claim",
|
||||
"correction_hint": "declare success-criteria (--success-criteria) to claim DONE",
|
||||
"run_id": args.run_id, "step": args.step, "profile": prof,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=policy is not None),
|
||||
}
|
||||
_emit(payload)
|
||||
return 1
|
||||
|
||||
payload = {
|
||||
"verdict": "PASS", "done": done,
|
||||
"run_id": args.run_id, "step": args.step, "profile": prof,
|
||||
"criteria_checked": checked,
|
||||
"provenance": lc.provenance("loop-gate", args.artifact, verified=policy is not None),
|
||||
}
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="CASAN Per-iteration Verify Contract (Plan-17 T3)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
v = sub.add_parser("verify", help="Verify one loop iteration (H4 + H3 contract)")
|
||||
v.add_argument("--run-id", required=True)
|
||||
v.add_argument("--step", type=int, required=True)
|
||||
v.add_argument("--artifact", required=True)
|
||||
v.add_argument("--success-criteria", default=None)
|
||||
v.add_argument("--claim-done", action="store_true")
|
||||
v.add_argument("--profile", default=None)
|
||||
v.add_argument("--delegation-level", default=None)
|
||||
v.add_argument("--project", default=None)
|
||||
v.set_defaults(func=cmd_verify)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"verdict": "FAIL", "reason": "fail_closed", "detail": str(exc)})
|
||||
return 1
|
||||
except Exception as exc: # never fail open
|
||||
_emit({"verdict": "FAIL", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Loop Budget Governor (Plan-17 Track 1, harness-owned).
|
||||
|
||||
The loop-breaker. Given a run's *cumulative* usage (steps / tokens / wall-clock /
|
||||
cost / corrections), decide whether the agent loop may CONTINUE or must HALT.
|
||||
|
||||
Deny-by-default & fail-closed:
|
||||
* no policy file -> strictest built-in ceiling (STRICT_CEILING),
|
||||
* present-but-corrupt policy -> HALT (exit 3), never "run on",
|
||||
* any unexpected error -> HALT (exit 3).
|
||||
|
||||
Loosening the ceiling is a security-sensitive setting: it must be changed through
|
||||
the governed settings store (control-plane-settings.py: approval JWT + SoD +
|
||||
versioned + rollback), not by editing this script. This governor only *reads* the
|
||||
(governed) policy — see Plan-17 17.3.
|
||||
|
||||
Usage:
|
||||
loop-governor.py check --run-id R --step N \
|
||||
[--tokens T] [--elapsed S] [--cost C] [--corrections K] \
|
||||
[--profile prod|dev] [--delegation-level L2] [--project okr]
|
||||
|
||||
Exit codes:
|
||||
0 CONTINUE (within ceiling)
|
||||
3 HALT(budget) (ceiling exceeded with on_exceed=halt, OR fail-closed error)
|
||||
4 ESCALATE (ceiling exceeded with on_exceed=escalate)
|
||||
2 usage error
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import loop_common as lc
|
||||
|
||||
|
||||
def _emit(payload, stream=sys.stdout):
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def cmd_check(args):
|
||||
usage = {
|
||||
"steps": args.step,
|
||||
"tokens": args.tokens,
|
||||
"elapsed_s": args.elapsed,
|
||||
"cost_usd": args.cost,
|
||||
"corrections": args.corrections,
|
||||
}
|
||||
prof = args.profile or lc.profile()
|
||||
|
||||
try:
|
||||
policy = lc.load_policy()
|
||||
ceiling, source = lc.resolve_budget(
|
||||
policy, prof, args.delegation_level, args.project
|
||||
)
|
||||
except lc.PolicyError as exc:
|
||||
# Fail-closed: a policy we cannot trust must stop the loop, not free it.
|
||||
payload = {
|
||||
"decision": "HALT",
|
||||
"reason": "fail_closed",
|
||||
"detail": str(exc),
|
||||
"run_id": args.run_id,
|
||||
"step": args.step,
|
||||
"profile": prof,
|
||||
"provenance": lc.provenance("loop-governor", lc.policy_path(), verified=False),
|
||||
}
|
||||
lc.append_audit({"kind": "governor_halt", **payload})
|
||||
_emit(payload)
|
||||
return 3
|
||||
|
||||
exceeded = []
|
||||
checks = (
|
||||
("steps", "max_steps", usage["steps"]),
|
||||
("tokens", "max_tokens", usage["tokens"]),
|
||||
("elapsed_s", "max_wall_clock_sec", usage["elapsed_s"]),
|
||||
("cost_usd", "max_cost_usd", usage["cost_usd"]),
|
||||
("corrections", "max_corrections_per_step", usage["corrections"]),
|
||||
)
|
||||
for usage_key, limit_key, value in checks:
|
||||
limit = ceiling[limit_key]
|
||||
if value is not None and value > limit:
|
||||
exceeded.append({"metric": usage_key, "value": value, "limit": limit})
|
||||
|
||||
on_exceed = ceiling.get("on_exceed", "halt")
|
||||
|
||||
if not exceeded:
|
||||
payload = {
|
||||
"decision": "CONTINUE",
|
||||
"run_id": args.run_id,
|
||||
"step": args.step,
|
||||
"profile": prof,
|
||||
"policy_source": source,
|
||||
"limits": ceiling,
|
||||
"usage": usage,
|
||||
"provenance": lc.provenance("loop-governor", lc.policy_path(), verified=policy is not None),
|
||||
}
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
decision = "ESCALATE" if on_exceed == "escalate" else "HALT"
|
||||
payload = {
|
||||
"decision": decision,
|
||||
"reason": "budget_exceeded",
|
||||
"run_id": args.run_id,
|
||||
"step": args.step,
|
||||
"profile": prof,
|
||||
"policy_source": source,
|
||||
"limits": ceiling,
|
||||
"usage": usage,
|
||||
"exceeded": exceeded,
|
||||
"on_exceed": on_exceed,
|
||||
"provenance": lc.provenance("loop-governor", lc.policy_path(), verified=policy is not None),
|
||||
}
|
||||
lc.append_audit({"kind": "governor_" + decision.lower(), **payload})
|
||||
_emit(payload)
|
||||
return 4 if decision == "ESCALATE" else 3
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="CASAN Loop Budget Governor (Plan-17 T1)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
c = sub.add_parser("check", help="Evaluate cumulative usage against the ceiling")
|
||||
c.add_argument("--run-id", required=True)
|
||||
c.add_argument("--step", type=int, required=True)
|
||||
c.add_argument("--tokens", type=int, default=None)
|
||||
c.add_argument("--elapsed", type=float, default=None)
|
||||
c.add_argument("--cost", type=float, default=None)
|
||||
c.add_argument("--corrections", type=int, default=None)
|
||||
c.add_argument("--profile", default=None)
|
||||
c.add_argument("--delegation-level", default=None)
|
||||
c.add_argument("--project", default=None)
|
||||
c.set_defaults(func=cmd_check)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "HALT", "reason": "fail_closed", "detail": str(exc)})
|
||||
return 3
|
||||
except Exception as exc: # never fail open
|
||||
_emit({"decision": "HALT", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN Loop Orchestrator (Plan-17 Track 6, harness-owned).
|
||||
#
|
||||
# Drives one governed agent loop. Every turn runs the loop primitives in order
|
||||
# (17.20): loop-gate (verify) -> loop-governor (budget) -> loop-convergence
|
||||
# (progress) -> loop-trace (record). The loop stops on the FIRST stop-condition:
|
||||
# DONE gate PASS on the artifact (success-criteria proven),
|
||||
# HALT governor budget exceeded (loop-breaker) or gate DENY (security),
|
||||
# ESCALATE convergence STALLED/OSCILLATING (or gate ESCALATE).
|
||||
#
|
||||
# Secure-by-default (17.20): governance is ON. In profile=prod, disabling it
|
||||
# (CASAN_LOOP_GOVERNANCE=off) is refused unless an explicit, audited opt-out
|
||||
# reason is given (CASAN_LOOP_OPTOUT_REASON) — reversing the ARCH-03 lesson.
|
||||
#
|
||||
# Between-turn context compaction (17.21) uses context-compress.py + must-keep
|
||||
# when --context is supplied, so the loop's growing context is kept bounded.
|
||||
#
|
||||
# Usage:
|
||||
# loop-run.sh --run-id R --artifact FILE [--success-criteria FILE] \
|
||||
# [--profile prod|dev] [--delegation-level L2] [--project okr] \
|
||||
# [--max-steps N] [--tokens-per-step T] [--cost-per-step C] \
|
||||
# [--context FILE]
|
||||
#
|
||||
# Exit codes: 0 DONE · 3 HALT/ESCALATE/DENY (governance stopped the loop) ·
|
||||
# 4 opt-out refused (prod) · 2 usage error.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GATE="$SCRIPT_DIR/loop-gate.py"
|
||||
GOV="$SCRIPT_DIR/loop-governor.py"
|
||||
CONV="$SCRIPT_DIR/loop-convergence.py"
|
||||
TRACE="$SCRIPT_DIR/loop-trace.py"
|
||||
COMPRESS="$SCRIPT_DIR/context-compress.py"
|
||||
|
||||
RUN_ID=""; ARTIFACT=""; CRIT=""; PROFILE=""; DLEVEL=""; PROJECT=""
|
||||
MAX_STEPS=25; TOKENS_PER_STEP=1000; COST_PER_STEP="0.01"; CONTEXT=""
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--run-id) RUN_ID="${2:-}"; shift 2 ;;
|
||||
--artifact) ARTIFACT="${2:-}"; shift 2 ;;
|
||||
--success-criteria) CRIT="${2:-}"; shift 2 ;;
|
||||
--profile) PROFILE="${2:-}"; shift 2 ;;
|
||||
--delegation-level) DLEVEL="${2:-}"; shift 2 ;;
|
||||
--project) PROJECT="${2:-}"; shift 2 ;;
|
||||
--max-steps) MAX_STEPS="${2:-}"; shift 2 ;;
|
||||
--tokens-per-step) TOKENS_PER_STEP="${2:-}"; shift 2 ;;
|
||||
--cost-per-step) COST_PER_STEP="${2:-}"; shift 2 ;;
|
||||
--context) CONTEXT="${2:-}"; shift 2 ;;
|
||||
*) echo "loop-run: unknown arg $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$RUN_ID" || -z "$ARTIFACT" ]]; then
|
||||
echo "Usage: loop-run.sh --run-id R --artifact FILE [--success-criteria FILE] ..." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PROFILE="${PROFILE:-${CASAN_PROFILE:-dev}}"
|
||||
prof_args=(--profile "$PROFILE")
|
||||
[[ -n "$DLEVEL" ]] && prof_args+=(--delegation-level "$DLEVEL")
|
||||
[[ -n "$PROJECT" ]] && prof_args+=(--project "$PROJECT")
|
||||
|
||||
sha_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}'
|
||||
else shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; fi
|
||||
}
|
||||
|
||||
json_field() { sed -n "s/.*\"$1\": \"\\([A-Za-z_]*\\)\".*/\\1/p" | head -1; }
|
||||
|
||||
# --- secure-by-default governance gate (17.20) ------------------------------
|
||||
GOVERNANCE="${CASAN_LOOP_GOVERNANCE:-on}"
|
||||
if [[ "$GOVERNANCE" == "off" ]]; then
|
||||
if [[ "$PROFILE" == "prod" && -z "${CASAN_LOOP_OPTOUT_REASON:-}" ]]; then
|
||||
echo "LOOP_REFUSE opt-out of loop governance requires CASAN_LOOP_OPTOUT_REASON in prod" >&2
|
||||
exit 4
|
||||
fi
|
||||
# An allowed opt-out is always audited (never silent).
|
||||
PYTHONPATH="$SCRIPT_DIR" python3 - "$RUN_ID" "${CASAN_LOOP_OPTOUT_REASON:-dev-optout}" <<'PY'
|
||||
import sys, loop_common as lc
|
||||
lc.append_audit({"kind": "loop_governance_optout", "run_id": sys.argv[1], "reason": sys.argv[2]})
|
||||
PY
|
||||
fi
|
||||
|
||||
echo "===== loop-run run_id=$RUN_ID profile=$PROFILE governance=$GOVERNANCE ====="
|
||||
|
||||
FINAL="DONE"; RC=0
|
||||
CUM_TOKENS=0
|
||||
COST_ACC="0"
|
||||
|
||||
for (( step=1; step<=MAX_STEPS; step++ )); do
|
||||
CUM_TOKENS=$(( CUM_TOKENS + TOKENS_PER_STEP ))
|
||||
COST_ACC="$(python3 -c "print(round($COST_ACC + $COST_PER_STEP, 6))")"
|
||||
DECISION="CONTINUE"; VERDICT=""; PROGRESS="0"
|
||||
|
||||
if [[ "$GOVERNANCE" == "on" ]]; then
|
||||
# 1) Governor: cumulative budget check (loop-breaker) BEFORE more work.
|
||||
set +e
|
||||
python3 "$GOV" check --run-id "$RUN_ID" --step "$step" \
|
||||
--tokens "$CUM_TOKENS" --cost "$COST_ACC" "${prof_args[@]}" >/dev/null 2>&1
|
||||
grc=$?
|
||||
set -e 2>/dev/null || true
|
||||
if [[ "$grc" -eq 3 ]]; then FINAL="HALT"; DECISION="HALT"; RC=3; fi
|
||||
if [[ "$grc" -eq 4 ]]; then FINAL="ESCALATE"; DECISION="ESCALATE"; RC=3; fi
|
||||
|
||||
if [[ "$DECISION" == "CONTINUE" ]]; then
|
||||
# 2) Gate: per-iteration verify contract.
|
||||
set +e
|
||||
GATE_OUT="$(python3 "$GATE" verify --run-id "$RUN_ID" --step "$step" \
|
||||
--artifact "$ARTIFACT" ${CRIT:+--success-criteria "$CRIT"} "${prof_args[@]}" 2>/dev/null)"
|
||||
set -e 2>/dev/null || true
|
||||
VERDICT="$(printf '%s' "$GATE_OUT" | json_field verdict)"
|
||||
case "$VERDICT" in
|
||||
PASS) FINAL="DONE"; DECISION="DONE"; PROGRESS="1"; RC=0 ;;
|
||||
DENY) FINAL="HALT"; DECISION="HALT"; RC=3 ;;
|
||||
ESCALATE) FINAL="ESCALATE"; DECISION="ESCALATE"; RC=3 ;;
|
||||
*) DECISION="CONTINUE"; PROGRESS="0" ;; # FAIL -> keep correcting
|
||||
esac
|
||||
fi
|
||||
|
||||
# 3) Convergence: observe + verdict (no-progress / oscillation breaker).
|
||||
if [[ "$DECISION" == "CONTINUE" ]]; then
|
||||
AH="$(sha_of "$ARTIFACT")"; AH="${AH:0:16}"
|
||||
python3 "$CONV" observe --run-id "$RUN_ID" --step "$step" \
|
||||
--action-hash "$AH" --progress "$PROGRESS" >/dev/null 2>&1
|
||||
set +e
|
||||
python3 "$CONV" verdict --run-id "$RUN_ID" "${prof_args[@]}" >/dev/null 2>&1
|
||||
cvrc=$?
|
||||
set -e 2>/dev/null || true
|
||||
if [[ "$cvrc" -eq 3 ]]; then FINAL="HALT"; DECISION="HALT"; RC=3; fi
|
||||
if [[ "$cvrc" -eq 4 ]]; then FINAL="ESCALATE"; DECISION="ESCALATE"; RC=3; fi
|
||||
fi
|
||||
else
|
||||
# Governance opted out: record turns only, terminate at max-steps as DONE.
|
||||
DECISION="CONTINUE"
|
||||
fi
|
||||
|
||||
# 4) Trace: append the immutable iteration record.
|
||||
python3 "$TRACE" record --run-id "$RUN_ID" --step "$step" \
|
||||
--intent "turn-$step" --action verify --tool loop-run \
|
||||
${VERDICT:+--gate-verdict "$VERDICT"} --decision "$DECISION" --progress "$PROGRESS" \
|
||||
--artifact "$ARTIFACT" ${CRIT:+--success-criteria "$CRIT"} \
|
||||
--budget-snapshot "{\"steps\":$step,\"tokens\":$CUM_TOKENS,\"cost_usd\":$COST_ACC}" >/dev/null 2>&1
|
||||
|
||||
# 5) Between-turn context compaction (17.21).
|
||||
if [[ -n "$CONTEXT" && -f "$CONTEXT" ]]; then
|
||||
python3 "$COMPRESS" --mode structural --input "$CONTEXT" \
|
||||
> "$(dirname "$ARTIFACT")/.loop-context-compacted.txt" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
[[ "$DECISION" == "CONTINUE" ]] || break
|
||||
done
|
||||
|
||||
echo "LOOP_RESULT run_id=$RUN_ID final=$FINAL last_step=$step tokens=$CUM_TOKENS cost=$COST_ACC"
|
||||
exit "$RC"
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Loop Trace / Replay (Plan-17 Track 4, harness-owned).
|
||||
|
||||
Records each loop iteration (§3 Loop Contract) into a per-run, append-only,
|
||||
hash-linked trace so a whole loop can be audited and deterministically replayed.
|
||||
Gives loops the "loop view" the audit chain lacked.
|
||||
|
||||
Subcommands:
|
||||
record append one Iteration to the run's trace (hash-linked to the prev).
|
||||
show render the loop view (JSON on stdout; a human table on stderr).
|
||||
replay re-run the per-iteration Verify Contract (loop-gate) on each
|
||||
recorded artifact and compare the fresh verdict to the recorded
|
||||
one -> detects non-determinism / tampered artifacts.
|
||||
verify-chain recompute the hash chain -> any edited/removed record BREAKs it.
|
||||
|
||||
Fail-closed & tail-safe (Plan-17 §2): the trace is append-only; replay never
|
||||
mutates the source trace (it runs loop-gate under throwaway run-ids). A corrupt
|
||||
trace file fails closed (verify-chain BREAK / show error).
|
||||
|
||||
KMS-anchoring of the chain head (17.16 full) is a TIER-2 add-on (Plan-07 B3 / A7);
|
||||
this offline slice proves local tamper-evidence without it.
|
||||
|
||||
Usage:
|
||||
loop-trace.py record --run-id R --step N --intent ... --action ... \
|
||||
[--tool T] [--inputs-ref REF] [--gate-verdict PASS|FAIL|DENY|ESCALATE] \
|
||||
[--progress P] [--decision CONTINUE|HALT|ESCALATE|DONE] \
|
||||
[--artifact PATH] [--success-criteria FILE] [--evidence-ref REF] \
|
||||
[--budget-snapshot JSON]
|
||||
loop-trace.py show --run-id R [--json]
|
||||
loop-trace.py replay --run-id R [--profile prod|dev]
|
||||
loop-trace.py verify-chain --run-id R
|
||||
|
||||
Exit codes:
|
||||
0 ok / chain intact / replay all-match
|
||||
3 chain BREAK, replay drift detected, or fail-closed error
|
||||
2 usage error
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import loop_common as lc
|
||||
|
||||
|
||||
def _emit(payload, stream=sys.stdout):
|
||||
json.dump(payload, stream, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def _trace_path(run_id):
|
||||
return os.path.join(lc.run_dir(run_id), "trace.jsonl")
|
||||
|
||||
|
||||
def _read_entries(run_id):
|
||||
"""Return the list of raw chain entries (each {seq, prev_hash, iteration, hash}).
|
||||
A malformed line fails closed."""
|
||||
path = _trace_path(run_id)
|
||||
entries = []
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except ValueError:
|
||||
raise lc.PolicyError("trace_corrupt")
|
||||
return entries
|
||||
|
||||
|
||||
def cmd_record(args):
|
||||
budget = None
|
||||
if args.budget_snapshot:
|
||||
try:
|
||||
budget = json.loads(args.budget_snapshot)
|
||||
except ValueError:
|
||||
raise lc.PolicyError("bad_budget_snapshot_json")
|
||||
|
||||
iteration = {
|
||||
"run_id": args.run_id,
|
||||
"step": args.step,
|
||||
"intent": args.intent,
|
||||
"action": args.action,
|
||||
"tool": args.tool,
|
||||
"inputs_ref": args.inputs_ref,
|
||||
"gate_verdict": args.gate_verdict,
|
||||
"progress": args.progress,
|
||||
"budget_snapshot": budget,
|
||||
"decision": args.decision,
|
||||
"artifact": args.artifact,
|
||||
"success_criteria": args.success_criteria,
|
||||
"evidence_ref": args.evidence_ref,
|
||||
"ts": lc.now_iso(),
|
||||
}
|
||||
|
||||
path = _trace_path(args.run_id)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
entries = _read_entries(args.run_id)
|
||||
prev = entries[-1]["hash"] if entries else lc.GENESIS_HASH
|
||||
seq = len(entries)
|
||||
base = {"seq": seq, "prev_hash": prev, "iteration": iteration}
|
||||
base["hash"] = lc.chain_hash(base)
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(lc.canonical(base) + "\n")
|
||||
_emit({"decision": "RECORDED", "run_id": args.run_id, "step": args.step, "seq": seq})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
entries = _read_entries(args.run_id)
|
||||
iterations = [e["iteration"] for e in entries]
|
||||
payload = {
|
||||
"run_id": args.run_id,
|
||||
"count": len(iterations),
|
||||
"iterations": iterations,
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=False),
|
||||
}
|
||||
# Human-readable loop view on stderr; machine JSON on stdout.
|
||||
hdr = f"{'seq':>3} {'step':>4} {'verdict':<9} {'decision':<10} {'progress':>8} intent"
|
||||
print(hdr, file=sys.stderr)
|
||||
for e in entries:
|
||||
it = e["iteration"]
|
||||
print(
|
||||
f"{e['seq']:>3} {str(it.get('step','')):>4} "
|
||||
f"{str(it.get('gate_verdict','')):<9} {str(it.get('decision','')):<10} "
|
||||
f"{str(it.get('progress','')):>8} {str(it.get('intent',''))[:48]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify_chain(args):
|
||||
try:
|
||||
entries = _read_entries(args.run_id)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "BREAK", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id})
|
||||
return 3
|
||||
prev = lc.GENESIS_HASH
|
||||
for i, e in enumerate(entries):
|
||||
stored = e.get("hash")
|
||||
base = {"seq": e.get("seq"), "prev_hash": e.get("prev_hash"), "iteration": e.get("iteration")}
|
||||
recomputed = lc.chain_hash(base)
|
||||
if e.get("prev_hash") != prev or e.get("seq") != i or recomputed != stored:
|
||||
_emit({
|
||||
"decision": "BREAK", "reason": "chain_broken", "at_seq": i,
|
||||
"run_id": args.run_id,
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=False),
|
||||
})
|
||||
return 3
|
||||
prev = stored
|
||||
_emit({
|
||||
"decision": "OK", "run_id": args.run_id, "records": len(entries),
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=True),
|
||||
})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_replay(args):
|
||||
try:
|
||||
entries = _read_entries(args.run_id)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "FAIL", "reason": "fail_closed", "detail": str(exc), "run_id": args.run_id})
|
||||
return 3
|
||||
|
||||
gate = os.path.join(os.path.dirname(__file__), "loop-gate.py")
|
||||
diffs = []
|
||||
replayed = 0
|
||||
for e in entries:
|
||||
it = e["iteration"]
|
||||
artifact = it.get("artifact")
|
||||
recorded = it.get("gate_verdict")
|
||||
if not artifact or not recorded:
|
||||
continue # only iterations with a verifiable artifact + verdict
|
||||
replayed += 1
|
||||
cmd = [
|
||||
sys.executable, gate, "verify",
|
||||
"--run-id", f"{args.run_id}-replay-{e['seq']}",
|
||||
"--step", str(it.get("step", 0)),
|
||||
"--artifact", artifact,
|
||||
]
|
||||
if it.get("success_criteria"):
|
||||
cmd += ["--success-criteria", it["success_criteria"]]
|
||||
if args.profile:
|
||||
cmd += ["--profile", args.profile]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
fresh = json.loads(r.stdout).get("verdict") if r.stdout.strip() else "ERROR"
|
||||
except Exception as exc:
|
||||
fresh = f"ERROR:{exc}"
|
||||
if fresh != recorded:
|
||||
diffs.append({"seq": e["seq"], "step": it.get("step"), "recorded": recorded, "replayed": fresh})
|
||||
|
||||
payload = {
|
||||
"run_id": args.run_id,
|
||||
"replayed": replayed,
|
||||
"diffs": diffs,
|
||||
"provenance": lc.provenance("loop-trace", _trace_path(args.run_id), verified=not diffs),
|
||||
}
|
||||
if diffs:
|
||||
payload["decision"] = "DRIFT"
|
||||
payload["reason"] = "verdict_mismatch"
|
||||
lc.append_audit({"kind": "trace_replay_drift", **payload})
|
||||
_emit(payload)
|
||||
return 3
|
||||
payload["decision"] = "MATCH"
|
||||
_emit(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(description="CASAN Loop Trace / Replay (Plan-17 T4)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
r = sub.add_parser("record", help="Append one Iteration to the run trace")
|
||||
r.add_argument("--run-id", required=True)
|
||||
r.add_argument("--step", type=int, required=True)
|
||||
r.add_argument("--intent", default=None)
|
||||
r.add_argument("--action", default=None)
|
||||
r.add_argument("--tool", default=None)
|
||||
r.add_argument("--inputs-ref", default=None)
|
||||
r.add_argument("--gate-verdict", default=None)
|
||||
r.add_argument("--progress", type=float, default=None)
|
||||
r.add_argument("--decision", default=None)
|
||||
r.add_argument("--artifact", default=None)
|
||||
r.add_argument("--success-criteria", default=None)
|
||||
r.add_argument("--evidence-ref", default=None)
|
||||
r.add_argument("--budget-snapshot", default=None)
|
||||
r.set_defaults(func=cmd_record)
|
||||
|
||||
s = sub.add_parser("show", help="Render the loop view")
|
||||
s.add_argument("--run-id", required=True)
|
||||
s.add_argument("--json", action="store_true")
|
||||
s.set_defaults(func=cmd_show)
|
||||
|
||||
rp = sub.add_parser("replay", help="Deterministically re-verify recorded artifacts")
|
||||
rp.add_argument("--run-id", required=True)
|
||||
rp.add_argument("--profile", default=None)
|
||||
rp.set_defaults(func=cmd_replay)
|
||||
|
||||
vc = sub.add_parser("verify-chain", help="Recompute the hash chain (tamper-evidence)")
|
||||
vc.add_argument("--run-id", required=True)
|
||||
vc.set_defaults(func=cmd_verify_chain)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except lc.PolicyError as exc:
|
||||
_emit({"decision": "FAIL", "reason": "fail_closed", "detail": str(exc)})
|
||||
return 3
|
||||
except Exception as exc: # never fail open
|
||||
_emit({"decision": "FAIL", "reason": "internal_error", "detail": str(exc)}, sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,417 @@
|
||||
"""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:
|
||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
|
||||
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
|
||||
return os.path.join(project_root(), ".specify/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
|
||||
Reference in New Issue
Block a user