feat: Block workspace edits before identity and capability verification Closes #210 #212

Closed
jcwalker3 wants to merge 6 commits from feat/issue-210-block-workspace-edits into master
2 changed files with 46 additions and 11 deletions
Showing only changes of commit eaead549c3 - Show all commits
+23 -11
View File
@@ -530,6 +530,7 @@ HANDOFF_BASE_FIELDS = (
("Files changed", ("files changed", "changed", "files")), ("Files changed", ("files changed", "changed", "files")),
("Validation", ("validation",)), ("Validation", ("validation",)),
("Mutations", ("mutations",)), ("Mutations", ("mutations",)),
("Workspace mutations", ("workspace mutations",)),
("Current status", ("current status", "status")), ("Current status", ("current status", "status")),
("Blockers", ("blockers",)), ("Blockers", ("blockers",)),
("Next", ("next",)), ("Next", ("next",)),
@@ -580,7 +581,7 @@ def _handoff_section_lines(report_text):
return lines[start:] return lines[start:]
def assess_controller_handoff(report_text, role=None): def assess_controller_handoff(report_text, role=None, local_edits=False):
"""Issue #182: final reports without a Controller Handoff downgrade. """Issue #182: final reports without a Controller Handoff downgrade.
Verdicts: Verdicts:
@@ -592,6 +593,7 @@ def assess_controller_handoff(report_text, role=None):
The handoff supplements the full report; this helper never validates The handoff supplements the full report; this helper never validates
the full report body, only the continuation summary. the full report body, only the continuation summary.
""" """
import re
section = _handoff_section_lines(report_text) section = _handoff_section_lines(report_text)
if section is None: if section is None:
return { return {
@@ -605,10 +607,14 @@ def assess_controller_handoff(report_text, role=None):
} }
labels = [] labels = []
fields_dict = {}
for line in section: for line in section:
stripped = line.strip().lstrip("-*").strip() stripped = line.strip().lstrip("-*").strip()
if ":" in stripped: if ":" in stripped:
labels.append(stripped.split(":", 1)[0].strip().lower()) k, v = stripped.split(":", 1)
label = k.strip().lower()
labels.append(label)
fields_dict[label] = v.strip()
required = list(HANDOFF_BASE_FIELDS) required = list(HANDOFF_BASE_FIELDS)
required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ())) required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ()))
@@ -629,13 +635,6 @@ def assess_controller_handoff(report_text, role=None):
} }
# Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196) # Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196)
fields_dict = {}
for line in section:
stripped = line.strip().lstrip("-*").strip()
if ":" in stripped:
k, v = stripped.split(":", 1)
fields_dict[k.strip().lower()] = v.strip()
for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"): for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"):
val = fields_dict.get(alias) val = fields_dict.get(alias)
if val: if val:
@@ -643,8 +642,8 @@ def assess_controller_handoff(report_text, role=None):
has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/")) has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/"))
if len(numbers) != 1 or has_forbidden: if len(numbers) != 1 or has_forbidden:
field_name = "Selected issue/PR" field_name = "Selected issue/PR"
for name, aliases in list(HANDOFF_BASE_FIELDS) + list(HANDOFF_ROLE_FIELDS.get(role or "", ())): for name, aliases_list in required:
if alias in aliases: if alias in aliases_list:
field_name = name field_name = name
break break
return { return {
@@ -656,6 +655,18 @@ def assess_controller_handoff(report_text, role=None):
], ],
} }
if local_edits:
workspace_mutations_val = fields_dict.get("workspace mutations", "").strip().lower()
if not workspace_mutations_val or workspace_mutations_val == "none":
return {
"verdict": "incomplete",
"downgraded": True,
"missing_fields": ["Workspace mutations"],
"reasons": [
"Workspace mutations cannot be 'none' or empty when local edits exist"
],
}
return { return {
"verdict": "complete", "verdict": "complete",
"downgraded": False, "downgraded": False,
@@ -664,6 +675,7 @@ def assess_controller_handoff(report_text, role=None):
} }
# ── PR Inventory Trust Gate (Issue #194) ────────────────────────────────────── # ── PR Inventory Trust Gate (Issue #194) ──────────────────────────────────────
# #
# A reviewer agent may not convert an empty PR list response into a definitive # A reviewer agent may not convert an empty PR list response into a definitive
+23
View File
@@ -710,6 +710,7 @@ class TestControllerHandoff(unittest.TestCase):
"- Files changed: review_proofs.py", "- Files changed: review_proofs.py",
"- Validation: 700 passed, 6 skipped", "- Validation: 700 passed, 6 skipped",
"- Mutations: one PR opened", "- Mutations: one PR opened",
"- Workspace mutations: none",
"- Current status: PR open", "- Current status: PR open",
"- Blockers: none", "- Blockers: none",
"- Next: review PR #999", "- Next: review PR #999",
@@ -830,6 +831,28 @@ class TestControllerHandoff(unittest.TestCase):
self.assertIn("assess_controller_handoff", skill) self.assertIn("assess_controller_handoff", skill)
self.assertIn("issue #182", skill) self.assertIn("issue #182", skill)
def test_handoff_rejects_none_workspace_mutations_when_local_edits_exist(self):
# 1. Workspace mutations: none is rejected when local_edits is True
incomplete_eq = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #203",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_eq, role="author", local_edits=True)
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Workspace mutations", res["missing_fields"])
# 2. Workspace mutations: edited files is allowed when local_edits is True
complete_eq = self.BASE_HANDOFF.replace("- Workspace mutations: none", "- Workspace mutations: edited review_proofs.py") + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #203",
"- No review/merge: confirmed",
])
res2 = assess_controller_handoff(complete_eq, role="author", local_edits=True)
self.assertEqual(res2["verdict"], "complete")
class TestPRInventoryTrustGate(unittest.TestCase): class TestPRInventoryTrustGate(unittest.TestCase):
"""Issue #194: unit tests for the PR inventory trust gate.""" """Issue #194: unit tests for the PR inventory trust gate."""