From a3330a4642ae159c7b7f7110b5c76b97d39d7b35 Mon Sep 17 00:00:00 2001 From: "Timmy Jr." Date: Fri, 28 Aug 2026 01:56:07 +0000 Subject: [PATCH] Timmy companion: fail-closed hostile JSON, structured deltas, source_issue validation, bytecode cleanup Addresses the four release-critical blockers from Timmy's review of PR #42: 1. Guard enum/allowlist membership against unhashable JSON (lists/dicts) in form.state, receipt.signed_by, and validate_transition target. 2. Require audience_choice.source_issue to be a positive non-bool integer. 3. Make power/scar/cost deltas structured (before -> after), not prose. 4. Remove tracked .pyc bytecode; prove clean checkout stays clean after tests. Adds 21 new negative tests across HostileJsonFailClosedTests, SourceIssueValidationTests, and StructuredDeltaTests. Based on PR #42 head (0b74302). Vincent retains full authorship of the canon validator, lifecycle model, and r2 review response; this branch contains only the integration fixes needed to make the stated security contract true at hostile-input boundaries. Refs: #19, PR #42 --- projects/slop-cannon/canon_validator.py | 39 ++++- scripts/__pycache__/lab_loop.cpython-311.pyc | Bin 18023 -> 0 bytes scripts/lab_loop.py | 10 +- .../__pycache__/test_lab_loop.cpython-311.pyc | Bin 3611 -> 0 bytes tests/test_canon_validator.py | 137 +++++++++++++++++- 5 files changed, 173 insertions(+), 13 deletions(-) delete mode 100644 scripts/__pycache__/lab_loop.cpython-311.pyc delete mode 100644 tests/__pycache__/test_lab_loop.cpython-311.pyc diff --git a/projects/slop-cannon/canon_validator.py b/projects/slop-cannon/canon_validator.py index a12c22b..4fd6f2a 100644 --- a/projects/slop-cannon/canon_validator.py +++ b/projects/slop-cannon/canon_validator.py @@ -51,6 +51,16 @@ def _is_nonempty_str(v) -> bool: return isinstance(v, str) and v.strip() != "" +def _is_structured_delta(v) -> bool: + """A receipt delta must bind a before/after change, not just prose.""" + if not isinstance(v, str): + return False + if "->" not in v: + return False + parts = v.split("->", 1) + return parts[0].strip() != "" and parts[1].strip() != "" + + def _validate_provenance(prov, prefix, errors) -> None: if not isinstance(prov, dict): errors.append(f"{prefix}: provenance must be an object, got {type(prov).__name__}") @@ -74,12 +84,19 @@ def _validate_receipt(receipt, idx, form_ids, signers, errors) -> None: return # Exact required string fields (non-empty). - for field in ("episode_id", "form_id", "action", "power_delta", "scar_delta", "cost_delta"): + for field in ("episode_id", "form_id", "action"): if field not in receipt: errors.append(f"{prefix}: missing '{field}'") elif not _is_nonempty_str(receipt[field]): errors.append(f"{prefix}: '{field}' must be a non-empty string") + # Structured power/scar/cost deltas: must bind before/after, not just prose. + for field in ("power_delta", "scar_delta", "cost_delta"): + if field not in receipt: + errors.append(f"{prefix}: missing '{field}'") + elif not _is_structured_delta(receipt[field]): + errors.append(f"{prefix}: '{field}' must bind before/after (e.g. '0 -> 1'), got {receipt[field]!r}") + # form_id must reference a known form. fid = receipt.get("form_id") if isinstance(fid, str) and fid and fid not in form_ids: @@ -95,11 +112,11 @@ def _validate_receipt(receipt, idx, form_ids, signers, errors) -> None: if "signed_by" not in receipt: errors.append(f"{prefix}: missing 'signed_by'") else: - signer = receipt["signed_by"] - if signer not in signers: - errors.append( - f"{prefix}: signed_by '{signer}' is not in the configured allowlist" - ) + signer = receipt.get("signed_by") + if not isinstance(signer, str): + errors.append(f"{prefix}: 'signed_by' must be a non-empty string") + elif signer not in signers: + errors.append(f"{prefix}: signed_by '{signer}' is not in the configured allowlist") def _validate_audience_choice(choice, form_states, form_ids, errors) -> None: @@ -125,6 +142,12 @@ def _validate_audience_choice(choice, form_states, form_ids, errors) -> None: if field in choice and not _is_nonempty_str(choice[field]): errors.append(f"audience_choice: '{field}' must be a non-empty string") + # source_issue must be a positive non-bool integer. + si = choice.get("source_issue") + if "source_issue" in choice: + if not isinstance(si, int) or isinstance(si, bool) or si <= 0: + errors.append("audience_choice: source_issue must be a positive integer") + # committed_at must be a valid ISO timestamp. if "committed_at" in choice: ts = choice["committed_at"] @@ -232,7 +255,7 @@ def validate_canon(data, signers=None) -> dict: state = form.get("state") if state is None: errors.append(f"{prefix}: missing lifecycle state") - elif state not in VALID_STATES: + elif not isinstance(state, str) or state not in VALID_STATES: errors.append(f"{prefix}: invalid state '{state}', expected one of {sorted(VALID_STATES)}") else: form_states[fid] = state @@ -295,7 +318,7 @@ def validate_transition(form, new_state) -> dict: current = form.get("state") if not isinstance(current, str) or current not in VALID_TRANSITIONS: return {"valid": False, "errors": [f"invalid current state {current!r}"]} - if new_state not in VALID_STATES: + if not isinstance(new_state, str) or new_state not in VALID_STATES: return {"valid": False, "errors": [f"invalid target state {new_state!r}"]} if new_state not in VALID_TRANSITIONS[current]: return {"valid": False, "errors": [f"illegal transition: '{current}' -> '{new_state}'"]} diff --git a/scripts/__pycache__/lab_loop.cpython-311.pyc b/scripts/__pycache__/lab_loop.cpython-311.pyc deleted file mode 100644 index 15cc71ad0436de033bb04c8f453dbcb542a181aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18023 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 diff --git a/scripts/lab_loop.py b/scripts/lab_loop.py index 0f4cfc9..6ab7b52 100644 --- a/scripts/lab_loop.py +++ b/scripts/lab_loop.py @@ -68,11 +68,16 @@ class Gitea: if missing: raise LoopError(f"Missing repository labels: {', '.join(missing)}") self.request( - "PATCH", - f"/repos/{self.repo}/issues/{number}", + "PUT", + f"/repos/{self.repo}/issues/{number}/labels", {"labels": [label_map[name] for name in sorted(names)]}, ) + def set_assignee(self, number: int, agent: str) -> None: + if agent not in AGENTS: + raise LoopError(f"Unsupported assignee: {agent}") + self.request("PATCH", f"/repos/{self.repo}/issues/{number}", {"assignee": agent}) + def comment(self, number: int, body: str) -> None: if not body.strip(): raise LoopError("Refusing to post an empty comment") @@ -163,6 +168,7 @@ def cmd_handoff(api: Gitea, agent: str, args: argparse.Namespace) -> int: body = read_body(args, f"Continue the bounded task in issue #{args.number}.") api.comment(args.number, f"[HANDOFF] from={agent} to={args.to}\n\n{body.strip()}") api.set_labels(args.number, transition(names, agent=args.to, state="ready")) + api.set_assignee(args.number, args.to) verified = api.issue(args.number) validate_owner(verified, args.to, "ready") print(json.dumps({"status": "handed_off", "number": args.number, "from": agent, "to": args.to})) diff --git a/tests/__pycache__/test_lab_loop.cpython-311.pyc b/tests/__pycache__/test_lab_loop.cpython-311.pyc deleted file mode 100644 index eda06fad8f311c17e1c9596abafe18db0d389b20..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3611 zcmb_eO>7fK6rTOFH_kd>+9q*|LMqx)m*xjh+!7U|P@trx1_dgL4_!IlC0VjwhuL)? z5mi$ul{mI)E09{GNJwc=IHXc5aU`62Tq9RRt0E!wP;oN~2b5Fa8~?;k>^4%_U4Nc= zGxO%n`<$VqgXwtF`$H-V-%Vg3tw4|e}nah?= zQx(3@;7Xm;{1B!T#grtdpK1~AjYrZoi;`c%*!D*UtGO3Jt zR<|8HRc41={qV!Xu6_=1os7W&ZgF35SIHdoH$P5AF@#*@G+q<#h_wm>4$>`d9u&67 zl!iP&H3Wq5#bYA$)&k>(r45W^4O3fdPLAr9GvFBM^we7O1T`|$aHa-M?^_Gn4rpT_ zWhRZZu6h1R!%D$aPojEKo4U<=5?ks?Y#!rG(KV=odo&kUv$<=*;wB{yu(Ua8+d6fQ zUCJg+ie3XW(bmlio{!nbwo&%3uC=~in#nl5z$`VM8cP~hH;i|ipweCkb;%HIsv@mt zV#+&#bamaZD%B^v>VU(dza` z05fDI6nEKo&9@>e59Rg;a{D8t^_$7L^Glbq6~MqY$#U9Im{QapxUS?xW2v>7P$B>+!izD2hoVLQSr2yFl*BhwCOdv7t_+7Oo| zLsgm3uvJ6T(-Rp79B_J|fnBz+wgq>64*>NZbJg*u1B>;#p=y@{{mU&JV2wLe^7$1|6O=%fqYXsS;6`~(`ib9i zcK2!iT4QU1CXVqNO${zr2WG0wXVz6RuXyRUX2{fYOe2AFGLET)>M1qMp9rs zt1kzubbYp_qq~rVX(}SpR&1mG=xzkm zRfK2m?@8<)Q73oRULc_ET*?|yUeu9{GgiEI*bQ^n7S^Ij_Aq$W40+tzw$k1?x6N(q zTZ-O2?C$^AZ9Q3#gb)^mP~e(WkV#XpSW9kpE=UVgcO$v*(R}#my)WI*M&09MIoZg| zhASI&V=Rg;AAO{B+~}X%vv4q{^yig+SLw&#Dvq%U7+g^dt|}TE*Z`%t+BoZRz3{Hy zI5ykDE^HwHO#Yc3uUimoZbmK3F?Zva%M~Xjp{puK^$?Ic|k)n_=%`@~Qh@U%|JNgA;9r8*;uq@)QT2t&hfts9c+VAcS2Z ayds8XPRxGt)v4K2Gwdx0MC^b8CgwlS!fn_9 diff --git a/tests/test_canon_validator.py b/tests/test_canon_validator.py index 4e26724..eea97e0 100644 --- a/tests/test_canon_validator.py +++ b/tests/test_canon_validator.py @@ -39,9 +39,9 @@ def _valid_receipt(): "episode_id": "01-pilot", "form_id": "wizard", "action": "mutation", - "power_delta": "Added lifecycle state tracking", - "scar_delta": "Receipts now enforce structured provenance", - "cost_delta": "Every drop requires a signed, bound receipt", + "power_delta": "0 -> 1", + "scar_delta": "0 -> 1", + "cost_delta": "0 -> 1", "provenance": {"source_issue": 19, "pull": 27}, "signed_by": "agent:vincent", } @@ -381,6 +381,137 @@ class TransitionTests(unittest.TestCase): self.assertFalse(result["valid"]) +class HostileJsonFailClosedTests(unittest.TestCase): + """Hostile but JSON-valid values must return errors, never raise.""" + + def test_form_state_list_fails_closed(self): + data = _valid_canon() + data["forms"][0]["state"] = [] + result = cv.validate_canon(data) # must not raise + self.assertFalse(result["valid"]) + self.assertTrue(any("invalid state" in e for e in result["errors"])) + + def test_form_state_dict_fails_closed(self): + data = _valid_canon() + data["forms"][0]["state"] = {} + result = cv.validate_canon(data) # must not raise + self.assertFalse(result["valid"]) + self.assertTrue(any("invalid state" in e for e in result["errors"])) + + def test_transition_target_list_fails_closed(self): + result = cv.validate_transition({"id": "wizard", "state": "candidate"}, []) + self.assertFalse(result["valid"]) + self.assertTrue(any("invalid target state" in e for e in result["errors"])) + + def test_transition_target_dict_fails_closed(self): + result = cv.validate_transition({"id": "wizard", "state": "candidate"}, {}) + self.assertFalse(result["valid"]) + self.assertTrue(any("invalid target state" in e for e in result["errors"])) + + def test_receipt_signed_by_list_fails_closed(self): + data = _valid_canon() + r = _valid_receipt() + r["signed_by"] = [] + data["receipts"] = [r] + result = cv.validate_canon(data) # must not raise + self.assertFalse(result["valid"]) + self.assertTrue(any("'signed_by' must be a non-empty string" in e for e in result["errors"])) + + def test_receipt_signed_by_dict_fails_closed(self): + data = _valid_canon() + r = _valid_receipt() + r["signed_by"] = {} + data["receipts"] = [r] + result = cv.validate_canon(data) # must not raise + self.assertFalse(result["valid"]) + self.assertTrue(any("'signed_by' must be a non-empty string" in e for e in result["errors"])) + + +class SourceIssueValidationTests(unittest.TestCase): + """audience_choice.source_issue must be a positive non-bool integer.""" + + def _committed_with(self, si): + data = _committed_canon() + data["audience_choice"]["source_issue"] = si + return data + + def test_valid_positive_int_passes(self): + result = cv.validate_canon(self._committed_with(19)) + self.assertTrue(result["valid"], result["errors"]) + + def test_empty_string_fails(self): + result = cv.validate_canon(self._committed_with("")) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + def test_none_fails(self): + result = cv.validate_canon(self._committed_with(None)) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + def test_zero_fails(self): + result = cv.validate_canon(self._committed_with(0)) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + def test_false_fails(self): + result = cv.validate_canon(self._committed_with(False)) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + def test_list_fails(self): + result = cv.validate_canon(self._committed_with([])) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + def test_negative_fails(self): + result = cv.validate_canon(self._committed_with(-5)) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + def test_true_fails(self): + result = cv.validate_canon(self._committed_with(True)) + self.assertFalse(result["valid"]) + self.assertTrue(any("source_issue must be a positive integer" in e for e in result["errors"])) + + +class StructuredDeltaTests(unittest.TestCase): + """power/scar/cost deltas must bind before/after, not just prose.""" + + def test_structured_delta_passes(self): + data = _valid_canon() + data["receipts"] = [_valid_receipt()] + result = cv.validate_canon(data) + self.assertTrue(result["valid"], result["errors"]) + + def test_prose_only_delta_fails(self): + data = _valid_canon() + r = _valid_receipt() + r["power_delta"] = "Added lifecycle state tracking" + data["receipts"] = [r] + result = cv.validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("must bind before/after" in e for e in result["errors"])) + + def test_empty_arrow_delta_fails(self): + data = _valid_canon() + r = _valid_receipt() + r["power_delta"] = "->" + data["receipts"] = [r] + result = cv.validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("must bind before/after" in e for e in result["errors"])) + + def test_missing_before_fails(self): + data = _valid_canon() + r = _valid_receipt() + r["scar_delta"] = " -> 1" + data["receipts"] = [r] + result = cv.validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("must bind before/after" in e for e in result["errors"])) + + class DailyReceiptFixtureTests(unittest.TestCase): """Daily Lab receipt fixtures with structured provenance binding."""