From 9b95b1f15bf9ba182ac43bc6dfdf5038e83fb685 Mon Sep 17 00:00:00 2001 From: cheshirecode Date: Wed, 29 Jul 2026 01:43:05 -0400 Subject: [PATCH] fix: guard loop state annotations --- skills/loop-engineering/SKILL.md | 6 +- .../loop-engineering/references/examples.md | 4 ++ .../loop-engineering/references/protocol.md | 9 ++- skills/loop-engineering/scripts/loop_state.py | 55 ++++++++++++++++--- .../loop-engineering/tests/test_loop_state.py | 49 +++++++++++++++++ tests/run.sh | 6 ++ 6 files changed, 116 insertions(+), 13 deletions(-) diff --git a/skills/loop-engineering/SKILL.md b/skills/loop-engineering/SKILL.md index f756e0c..7868eab 100644 --- a/skills/loop-engineering/SKILL.md +++ b/skills/loop-engineering/SKILL.md @@ -56,9 +56,9 @@ immediately in the same invocation. Do not yield an intermediate result or ask again. Yield only for a terminal outcome, user interruption, or real runtime boundary. -If later evidence contradicts a recorded fact, use `annotate --evidence -""`. Preserve the audit trail; do not reopen or hand-edit terminal -state. +If later evidence contradicts a recorded fact, capture `fingerprint --state +`, then use `annotate --expect-sha256 --evidence +""`. Preserve the audit trail; do not reopen terminal state. Use `finish` for `blocked`, `needs_human`, `cancelled`, or `continue_scheduled`. Never translate those states or `budget_exhausted` into diff --git a/skills/loop-engineering/references/examples.md b/skills/loop-engineering/references/examples.md index c616e48..79d4545 100644 --- a/skills/loop-engineering/references/examples.md +++ b/skills/loop-engineering/references/examples.md @@ -35,8 +35,12 @@ If a fresh check later contradicts evidence in a saved terminal run, append the correction and revalidate the state: ```bash +python3 /scripts/loop_state.py fingerprint \ + --state +# Pass the exact printed value from the expected snapshot. python3 /scripts/loop_state.py annotate \ --state \ + --expect-sha256 \ --evidence "Correction: isolated rerun reproduced the timing failure" python3 /scripts/loop_state.py validate --state python3 /scripts/loop_state.py show --state diff --git a/skills/loop-engineering/references/protocol.md b/skills/loop-engineering/references/protocol.md index ac44a63..8eec8eb 100644 --- a/skills/loop-engineering/references/protocol.md +++ b/skills/loop-engineering/references/protocol.md @@ -26,9 +26,12 @@ the JSON. declared units. Resumable outcomes require `--next-action`; `complete` and `cancelled` reject that flag and clear the prior running action. Every executed cycle must be accounted exactly once by `advance` or `finish`. -- `annotate` appends corrected evidence without reopening terminal state or - changing the consumed budget. It cannot add a next action to `complete` or - `cancelled`. +- `fingerprint` validates a state and prints the SHA-256 of its exact bytes. +- `annotate --expect-sha256 ` appends corrected evidence without + reopening terminal state or changing the consumed budget. Capture the + fingerprint after the last successful transition. A mismatch rejects the + write; verify state ownership instead of refreshing an unexpected mismatch. + Annotation cannot add a next action to `complete` or `cancelled`. - `validate` checks the schema and transition invariants. - `show` prints the five-field contract; `--json` returns the full history. diff --git a/skills/loop-engineering/scripts/loop_state.py b/skills/loop-engineering/scripts/loop_state.py index 3367451..f2f70f7 100755 --- a/skills/loop-engineering/scripts/loop_state.py +++ b/skills/loop-engineering/scripts/loop_state.py @@ -41,18 +41,29 @@ def now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") -def read_state(path: pathlib.Path) -> dict[str, Any]: +def read_state_snapshot(path: pathlib.Path) -> tuple[dict[str, Any], bytes]: try: - state = json.loads(path.read_text()) + raw_state = path.read_bytes() except FileNotFoundError as exc: raise StateError(f"state file does not exist: {path}") from exc + try: + state = json.loads(raw_state) except json.JSONDecodeError as exc: raise StateError(f"state file is not valid JSON: {path}: {exc}") from exc validate_state(state) - return state + return state, raw_state + + +def read_state(path: pathlib.Path) -> dict[str, Any]: + return read_state_snapshot(path)[0] -def write_state(path: pathlib.Path, state: dict[str, Any]) -> None: +def write_state( + path: pathlib.Path, + state: dict[str, Any], + *, + expect_sha256: str | None = None, +) -> None: validate_state(state) path.parent.mkdir(parents=True, exist_ok=True) fd, temporary_name = tempfile.mkstemp( @@ -67,6 +78,15 @@ def write_state(path: pathlib.Path, state: dict[str, Any]) -> None: handle.write("\n") handle.flush() os.fsync(handle.fileno()) + if expect_sha256 is not None: + try: + actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest() + except FileNotFoundError as exc: + raise StateError("state disappeared before guarded write") from exc + if actual_sha256 != expect_sha256: + raise StateError( + "state fingerprint changed during annotation; refusing write" + ) os.replace(temporary_path, path) finally: temporary_path.unlink(missing_ok=True) @@ -264,7 +284,13 @@ def command_finish(args: argparse.Namespace) -> dict[str, Any]: def command_annotate(args: argparse.Namespace) -> dict[str, Any]: - state = read_state(args.state) + state, raw_state = read_state_snapshot(args.state) + actual_sha256 = hashlib.sha256(raw_state).hexdigest() + if args.expect_sha256 != actual_sha256: + raise StateError( + "state fingerprint changed; refusing annotation " + f"(expected {args.expect_sha256}, actual {actual_sha256})" + ) if ( state["terminal_status"] in NON_RESUMABLE_STATUSES and args.next_action is not None @@ -274,10 +300,15 @@ def command_annotate(args: argparse.Namespace) -> dict[str, Any]: if args.next_action is not None: state["next_action"] = args.next_action append_history(state, "annotated", args.evidence, state["next_action"]) - write_state(args.state, state) + write_state(args.state, state, expect_sha256=actual_sha256) return state +def command_fingerprint(args: argparse.Namespace) -> str: + _, raw_state = read_state_snapshot(args.state) + return hashlib.sha256(raw_state).hexdigest() + + def summary(state: dict[str, Any]) -> str: budget = state["budget"] return "\n".join( @@ -348,10 +379,18 @@ def build_parser() -> argparse.ArgumentParser: help="append corrected evidence without changing status or budget", ) add_state_argument(annotate) + annotate.add_argument("--expect-sha256", required=True) annotate.add_argument("--evidence", action="append", required=True) annotate.add_argument("--next-action") annotate.set_defaults(handler=command_annotate) + fingerprint = subparsers.add_parser( + "fingerprint", + help="print the SHA-256 of a validated state snapshot", + ) + add_state_argument(fingerprint) + fingerprint.set_defaults(handler=command_fingerprint) + validate = subparsers.add_parser("validate", help="validate an existing state") add_state_argument(validate) validate.set_defaults(handler=lambda args: read_state(args.state)) @@ -377,7 +416,9 @@ def main() -> int: except StateError as exc: print(f"loop-state: {exc}", file=sys.stderr) return 3 - if args.command == "show" and not args.json: + if args.command == "fingerprint": + print(state) + elif args.command == "show" and not args.json: print(summary(state)) else: print(json.dumps(state, indent=2, sort_keys=True)) diff --git a/skills/loop-engineering/tests/test_loop_state.py b/skills/loop-engineering/tests/test_loop_state.py index 427fd89..50d83b1 100755 --- a/skills/loop-engineering/tests/test_loop_state.py +++ b/skills/loop-engineering/tests/test_loop_state.py @@ -60,6 +60,13 @@ def initialize(self, limit: int = 2) -> dict[str, object]: ) return json.loads(result.stdout) + def fingerprint(self) -> str: + return self.run_cli( + "fingerprint", + "--state", + str(self.state), + ).stdout.strip() + def test_init_and_validate(self) -> None: state = self.initialize() self.assertEqual(state["terminal_status"], "running") @@ -353,11 +360,14 @@ def test_annotate_rejects_next_action_for_non_resumable_terminal(self) -> None: "--evidence", "operator cancelled the run", ) + expected_sha256 = self.fingerprint() before = self.state.read_text() result = self.run_cli( "annotate", "--state", str(self.state), + "--expect-sha256", + expected_sha256, "--evidence", "correction: cancellation was explicit", "--next-action", @@ -416,11 +426,14 @@ def test_annotate_corrects_terminal_evidence_without_reopening(self) -> None: "obtain target repository", ).stdout ) + expected_sha256 = self.fingerprint() corrected = json.loads( self.run_cli( "annotate", "--state", str(self.state), + "--expect-sha256", + expected_sha256, "--evidence", "correction: cwd has .git but is not the target repository", ).stdout @@ -429,6 +442,42 @@ def test_annotate_corrects_terminal_evidence_without_reopening(self) -> None: self.assertEqual(corrected["budget"], exhausted["budget"]) self.assertEqual(corrected["history"][-1]["event"], "annotated") + def test_fingerprint_matches_exact_state_bytes(self) -> None: + self.initialize() + + self.assertEqual( + self.fingerprint(), + hashlib.sha256(self.state.read_bytes()).hexdigest(), + ) + + def test_annotate_rejects_stale_fingerprint_atomically(self) -> None: + self.initialize() + stale_sha256 = self.fingerprint() + self.run_cli( + "advance", + "--state", + str(self.state), + "--evidence", + "another session advanced the run", + "--next-action", + "continue from the newer snapshot", + ) + before = self.state.read_text() + + result = self.run_cli( + "annotate", + "--state", + str(self.state), + "--expect-sha256", + stale_sha256, + "--evidence", + "stale session correction", + expected_returncode=3, + ) + + self.assertIn("state fingerprint changed", result.stderr) + self.assertEqual(self.state.read_text(), before) + if __name__ == "__main__": unittest.main() diff --git a/tests/run.sh b/tests/run.sh index 817021e..92d55f1 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -187,6 +187,12 @@ checks = { and "divergent-copy" in install_script ), "atomic state write": "os.replace" in state_script, + "fingerprint-guarded correction": ( + "fingerprint --state" in root + and "--expect-sha256" in root + and "state fingerprint changed" in state_script + and "verify state ownership" in protocol + ), "host differences deferred": references == {"examples.md", "hosts.md", "protocol.md"}, "cross-host continuation": ( hosts.count("while state is `running`") >= 3