Skip to content

Commit 35d0a0b

Browse files
committed
merge: forge/chain governance + agentplane seal client
2 parents 906a511 + 6bc27d8 commit 35d0a0b

4 files changed

Lines changed: 590 additions & 2 deletions

File tree

assets/sourceos/bin/turtle-agentctl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,11 @@ def main(argv: list[str]) -> int:
401401
cop_mem_p.add_argument("--limit", type=int, default=0)
402402
cop_ev_p = sub.add_parser("copilot-evidence", help="show recent reasoning evidence (ReasoningEvents)")
403403
cop_ev_p.add_argument("--limit", type=int, default=20)
404+
# reasoning seal (agentplane)
405+
seal_p = sub.add_parser("reasoning-seal", help="seal a local reasoning receipt via agentplane")
406+
seal_p.add_argument("run", nargs="?", default="latest", help="run hex/urn or 'latest'")
407+
seal_verify_p = sub.add_parser("reasoning-seal-verify", help="verify a sealed reasoning record")
408+
seal_verify_p.add_argument("sealed_path", help="path to the sealed record")
404409
# gh parity
405410
repo_create_p = sub.add_parser("gh-repo-create")
406411
repo_create_p.add_argument("name", nargs="?", default="")
@@ -769,6 +774,10 @@ def main(argv: list[str]) -> int:
769774
request = {"action": "copilot_memory_list", "limit": getattr(args, "limit", 0)}
770775
elif args.command == "copilot-evidence":
771776
request = {"action": "copilot_evidence", "limit": getattr(args, "limit", 20)}
777+
elif args.command == "reasoning-seal":
778+
request = {"action": "reasoning_seal", "run": getattr(args, "run", "latest")}
779+
elif args.command == "reasoning-seal-verify":
780+
request = {"action": "reasoning_seal_verify", "sealed_path": getattr(args, "sealed_path", "")}
772781
elif args.command == "gh-repo-create":
773782
request = {"action": "gh_repo_create", "name": getattr(args, "name", ""), "private": getattr(args, "private", False), "org": getattr(args, "org", "")}
774783
elif args.command == "gh-repo-fork":

assets/sourceos/bin/turtle-agentd

Lines changed: 316 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,169 @@ def _emit_replay_plan(run: dict[str, Any], replay_class: str,
360360
return plan_id
361361

362362

363+
# ---------------------------------------------------------------------------
364+
# agentplane seal client (Advance 2)
365+
#
366+
# Seal local reasoning receipts via agentplane (the evidence-sealing authority),
367+
# turning local receipts into externally-verifiable sealed evidence. The shared
368+
# seal contract is documented in the task spec; the agentplane side is built by
369+
# a parallel agent to match exactly.
370+
# ---------------------------------------------------------------------------
371+
372+
def agentplane_dir() -> Path:
373+
return Path(env("AGENTPLANE_DIR", str(Path.home() / "dev" / "agentplane")))
374+
375+
376+
def agentplane_seal_tool() -> Path:
377+
return agentplane_dir() / "tools" / "seal_reasoning_receipt.py"
378+
379+
380+
def _resolve_reasoning_run_dir(run_spec: str) -> Path | None:
381+
"""Resolve a run dir under reasoning_evidence_root().
382+
383+
`run_spec` is a run hex, a full run URN, or "latest". For "latest", pick the
384+
most recently modified <hex>/receipt.json. Returns the run dir or None.
385+
"""
386+
root = reasoning_evidence_root()
387+
spec = (run_spec or "latest").strip()
388+
if spec and spec.lower() != "latest":
389+
run_hex = spec.rsplit(":", 1)[-1] if ":" in spec else spec
390+
cand = root / run_hex
391+
if (cand / "receipt.json").exists():
392+
return cand
393+
return None
394+
# latest: newest receipt.json by mtime
395+
best: tuple[float, Path] | None = None
396+
try:
397+
for d in root.iterdir():
398+
if not d.is_dir():
399+
continue
400+
rc = d / "receipt.json"
401+
if rc.exists():
402+
m = rc.stat().st_mtime
403+
if best is None or m > best[0]:
404+
best = (m, d)
405+
except FileNotFoundError:
406+
return None
407+
return best[1] if best else None
408+
409+
410+
def _do_reasoning_seal(request: dict[str, Any]) -> dict[str, Any]:
411+
"""Seal a local reasoning receipt via the agentplane seal tool.
412+
413+
Exception-safe and graceful: if agentplane is absent, returns
414+
{sealed: false, reason: "agentplane not available", ...} rather than erroring.
415+
"""
416+
try:
417+
explicit_receipt = request.get("receipt")
418+
if explicit_receipt:
419+
receipt_path = Path(str(explicit_receipt)).expanduser()
420+
run_dir = receipt_path.parent
421+
else:
422+
run_dir = _resolve_reasoning_run_dir(str(request.get("run", "latest")))
423+
if run_dir is None:
424+
return response("reasoning_seal", {
425+
"sealed": False,
426+
"reason": "no reasoning receipt found",
427+
"run": request.get("run", "latest"),
428+
})
429+
receipt_path = run_dir / "receipt.json"
430+
431+
run_json = run_dir / "run.json"
432+
events_path = reasoning_event_stream_path()
433+
sealed_dir = run_dir / "sealed"
434+
435+
tool = agentplane_seal_tool()
436+
if not tool.exists():
437+
return response("reasoning_seal", {
438+
"sealed": False,
439+
"reason": "agentplane not available",
440+
"checked": str(tool),
441+
"run": request.get("run", "latest"),
442+
})
443+
444+
sealed_dir.mkdir(parents=True, exist_ok=True)
445+
cmd = [
446+
sys.executable, str(tool),
447+
"--receipt", str(receipt_path),
448+
"--run", str(run_json),
449+
"--events", str(events_path),
450+
"--out-dir", str(sealed_dir),
451+
]
452+
proc = subprocess.run(
453+
cmd, check=False, text=True,
454+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30,
455+
)
456+
out = (proc.stdout or "").strip()
457+
parsed: dict[str, Any] = {}
458+
for line in reversed(out.splitlines()):
459+
line = line.strip()
460+
if line.startswith("{"):
461+
try:
462+
parsed = json.loads(line)
463+
break
464+
except json.JSONDecodeError:
465+
continue
466+
if not parsed:
467+
return response("reasoning_seal", {
468+
"sealed": False,
469+
"reason": "seal tool returned no parseable result",
470+
"stderr": redact_secrets((proc.stderr or "")[:200]),
471+
"run": request.get("run", "latest"),
472+
})
473+
return response("reasoning_seal", {
474+
"sealed": bool(parsed.get("sealed")),
475+
"evidence_id": parsed.get("evidence_id"),
476+
"sealed_path": parsed.get("sealed_path"),
477+
"seal_hash": parsed.get("seal_hash"),
478+
"run": request.get("run", "latest"),
479+
})
480+
except Exception as exc:
481+
return response("reasoning_seal", {
482+
"sealed": False,
483+
"reason": f"seal error: {exc}",
484+
"run": request.get("run", "latest"),
485+
})
486+
487+
488+
def _do_reasoning_seal_verify(request: dict[str, Any]) -> dict[str, Any]:
489+
"""Verify a sealed reasoning record via the agentplane seal tool."""
490+
try:
491+
sealed_path = request.get("sealed_path")
492+
if not sealed_path:
493+
return response("reasoning_seal_verify", {
494+
"verified": False, "reason": "sealed_path required"})
495+
tool = agentplane_seal_tool()
496+
if not tool.exists():
497+
return response("reasoning_seal_verify", {
498+
"verified": False,
499+
"reason": "agentplane not available",
500+
"checked": str(tool),
501+
})
502+
proc = subprocess.run(
503+
[sys.executable, str(tool), "--verify", str(sealed_path)],
504+
check=False, text=True,
505+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30,
506+
)
507+
out = (proc.stdout or "").strip()
508+
for line in reversed(out.splitlines()):
509+
line = line.strip()
510+
if line.startswith("{"):
511+
try:
512+
parsed = json.loads(line)
513+
return response("reasoning_seal_verify", parsed)
514+
except json.JSONDecodeError:
515+
continue
516+
return response("reasoning_seal_verify", {
517+
"verified": False,
518+
"reason": "verify tool returned no parseable result",
519+
"stderr": redact_secrets((proc.stderr or "")[:200]),
520+
})
521+
except Exception as exc:
522+
return response("reasoning_seal_verify", {
523+
"verified": False, "reason": f"verify error: {exc}"})
524+
525+
363526
def git_info(cwd: str | None = None) -> dict[str, str | None]:
364527
"""Return {repo_root, git_branch} for cwd, or nulls if not a git repo."""
365528
kwargs: dict[str, Any] = {
@@ -1554,11 +1717,153 @@ def _adaptive_plan_step(goal: str, step_idx: int, step: dict[str, Any], step_out
15541717
return step
15551718

15561719

1720+
# ---------------------------------------------------------------------------
1721+
# Forge / chain action governance (Advance 1)
1722+
#
1723+
# Every attestable forge/chain action is governed at the dispatch chokepoint:
1724+
# we open a ReasoningRun, run the real handler, emit a single safe ReasoningEvent
1725+
# summarizing the outcome, close the run, and inject the run + receipt URNs into
1726+
# the returned payload. This means 30+ action branches are attested without
1727+
# touching any of them. ALL governing is exception-safe: any failure falls back
1728+
# to the un-governed inner result so evidence emission can never break an action.
1729+
# ---------------------------------------------------------------------------
1730+
1731+
# Substrings that mark a forge/chain action as mutating (has side effects).
1732+
_MUTATING_FORGE_TOKENS = (
1733+
"create", "fork", "merge", "close", "comment", "delete", "release",
1734+
"secret", "label_create", "edit", "update",
1735+
)
1736+
# Actions that are always mutating regardless of token analysis.
1737+
_ALWAYS_MUTATING_ACTIONS = ("chain_run", "session_to_pr")
1738+
1739+
1740+
def _normalize_action(action: Any) -> str:
1741+
"""Lowercase + dashes->underscores for policy classification."""
1742+
if not isinstance(action, str):
1743+
return ""
1744+
return action.strip().lower().replace("-", "_")
1745+
1746+
1747+
def is_attestable_action(action: Any) -> bool:
1748+
"""True if `action` is a forge/chain control op that must be governed."""
1749+
norm = _normalize_action(action)
1750+
if not norm:
1751+
return False
1752+
if norm in ("chain_run", "session_to_pr"):
1753+
return True
1754+
return norm.startswith("gh_") or norm.startswith("gitea_")
1755+
1756+
1757+
def is_mutating_action(action: Any) -> bool:
1758+
"""Classify an attestable action as mutating (side-effect) vs read-only."""
1759+
norm = _normalize_action(action)
1760+
if norm in _ALWAYS_MUTATING_ACTIONS:
1761+
return True
1762+
return any(tok in norm for tok in _MUTATING_FORGE_TOKENS)
1763+
1764+
1765+
def _forge_event_summary(action: str, resp: dict[str, Any]) -> str:
1766+
"""Derive a short, safe one-liner from the response's top-level fields.
1767+
1768+
NEVER dumps the full resp. Looks only at coarse status fields.
1769+
"""
1770+
data = resp.get("data") if isinstance(resp, dict) else None
1771+
src = data if isinstance(data, dict) else (resp if isinstance(resp, dict) else {})
1772+
bits: list[str] = []
1773+
for key in ("created", "merged", "closed", "forge", "repo", "name",
1774+
"number", "url", "online", "ok", "run"):
1775+
if key in src and not isinstance(src[key], (dict, list)):
1776+
bits.append(f"{key}={src[key]}")
1777+
tail = ", ".join(bits[:4]) if bits else "done"
1778+
return redact_secrets(f"{action} -> {tail}")[:200]
1779+
1780+
1781+
def _resp_indicates_failure(resp: dict[str, Any]) -> bool:
1782+
"""Heuristic: did the wrapped action fail?
1783+
1784+
Conservative: only the top-level error status, or an explicit mutating-action
1785+
failure flag (ok/created/merged == False), counts as a failure. A benign
1786+
field like `online: false` or `error` on a read-only status response is NOT a
1787+
failure — being offline is a valid, successful read-only observation.
1788+
"""
1789+
if not isinstance(resp, dict):
1790+
return False
1791+
if resp.get("status") == "error":
1792+
return True
1793+
data = resp.get("data")
1794+
if isinstance(data, dict):
1795+
for key in ("created", "merged", "closed", "deleted", "ok"):
1796+
if data.get(key) is False:
1797+
return True
1798+
return False
1799+
1800+
1801+
def handle_request(request: dict[str, Any]) -> dict[str, Any]:
1802+
"""Governing wrapper around dispatch.
1803+
1804+
For attestable forge/chain actions, open a ReasoningRun, run the real
1805+
handler, emit a safe summary event, close the run, and inject the run +
1806+
receipt URNs into the returned payload. Otherwise dispatch unchanged.
1807+
"""
1808+
action = request.get("action")
1809+
if not is_attestable_action(action):
1810+
return _handle_request_inner(request)
1811+
1812+
# Open the run BEFORE dispatch, but keep the run open in a way that a
1813+
# failure during governing never re-runs the (possibly mutating) action.
1814+
run: dict[str, Any] | None = None
1815+
try:
1816+
run = _open_reasoning_run(
1817+
task_summary=f"forge action: {action}", agent="turtle-forge")
1818+
except Exception:
1819+
run = None
1820+
1821+
# Dispatch the real action exactly once, OUTSIDE the governing try/except.
1822+
resp = _handle_request_inner(request)
1823+
1824+
if run is None:
1825+
return resp
1826+
1827+
try:
1828+
action_norm = _normalize_action(action)
1829+
action_dashed = action_norm.replace("_", "-")
1830+
mutating = is_mutating_action(action)
1831+
replay_class = "non-replayable-side-effect" if mutating else "evidence-only"
1832+
1833+
failed = _resp_indicates_failure(resp)
1834+
run_status = "failed" if failed else "completed"
1835+
data = resp.get("data") if isinstance(resp, dict) else None
1836+
forge_val = data.get("forge") if isinstance(data, dict) else None
1837+
_emit_reasoning_event(
1838+
run,
1839+
event_type=f"forge.{action_dashed}",
1840+
summary=_forge_event_summary(action_norm, resp),
1841+
trust_level="trusted-control-input",
1842+
trace_level="workspace-safe",
1843+
extra={
1844+
"action": action_norm,
1845+
"forge": forge_val,
1846+
"ok": not failed,
1847+
},
1848+
)
1849+
receipt = _close_reasoning_run(run, status=run_status, replay_class=replay_class)
1850+
1851+
# Inject run + receipt URNs into the returned payload's data dict.
1852+
if isinstance(resp, dict) and isinstance(resp.get("data"), dict):
1853+
resp["data"]["reasoning_run"] = run.get("id", "")
1854+
resp["data"]["reasoning_receipt"] = receipt.get("id", "")
1855+
return resp
1856+
except Exception:
1857+
# Evidence/governing failures must never break the action: the action
1858+
# already ran exactly once above, so just return its result unwrapped.
1859+
return resp
1860+
1861+
15571862
# ---------------------------------------------------------------------------
15581863
# Request handler
15591864
# ---------------------------------------------------------------------------
15601865

1561-
def handle_request(request: dict[str, Any]) -> dict[str, Any]: # noqa: C901
1866+
def _handle_request_inner(request: dict[str, Any]) -> dict[str, Any]: # noqa: C901
15621867
action = request.get("action")
15631868

15641869
# ------------------------------------------------------------------
@@ -3204,7 +3509,7 @@ def handle_request(request: dict[str, Any]) -> dict[str, Any]: # noqa: C901
32043509
"steps": [{"command": s.get("cmd", s) if isinstance(s, dict) else str(s),
32053510
"description": s.get("desc", "") if isinstance(s, dict) else ""} for s in steps],
32063511
}
3207-
return handle_request(plan_request)
3512+
return _handle_request_inner(plan_request)
32083513

32093514
if action in ("coach_analyze", "coach-analyze"):
32103515
"""Detect inefficient command patterns and teach better alternatives."""
@@ -5270,6 +5575,15 @@ done
52705575
"runs": run_count,
52715576
})
52725577

5578+
# ------------------------------------------------------------------
5579+
# agentplane seal client (Advance 2)
5580+
# ------------------------------------------------------------------
5581+
if action in ("reasoning_seal", "reasoning-seal"):
5582+
return _do_reasoning_seal(request)
5583+
5584+
if action in ("reasoning_seal_verify", "reasoning-seal-verify"):
5585+
return _do_reasoning_seal_verify(request)
5586+
52735587
# ------------------------------------------------------------------
52745588
# gh-parity forge actions
52755589
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)