"""Tests for fail-closed subagent delegation gates (#266). Covers the acceptance criteria: blocked write delegation, allowed read-only delegation, missing inherited context, and invalid subagent final reports. """ import unittest import subagent_gate def _full_context(): return { "issue_lock": "issue #266 locked to feat/issue-266-subagent-gate-inheritance", "branch": "feat/issue-266-subagent-gate-inheritance", "worktree_path": "branches/feat-issue-266-subagent-gate-inheritance", "identity_profile": "jcwalker3/prgs-author", "allowed_tool_class": "read_write_files", "command_deny_list": "git push --force; rm -rf", "validation_ledger_requirement": "record command/exit/output for every validation claim", "final_report_schema": "controller-handoff-v1", } def _full_report(): return { "identity_profile": "jcwalker3/prgs-author", "worktree_path": "branches/feat-issue-266-subagent-gate-inheritance", "branch": "feat/issue-266-subagent-gate-inheritance", "changed_files": "subagent_gate.py, tests/test_subagent_gate.py", "validation_results": "pytest tests/test_subagent_gate.py -q: exit 0, 14 passed", "workspace_mutations": "edited subagent_gate.py", } class TestAssessSubagentDelegation(unittest.TestCase): # AC1: deterministic write tasks are blocked by default def test_write_delegation_blocked_by_default(self): for task in ("claim_issue", "create_branch", "edit_code", "commit", "push_branch", "create_pr", "review_pr", "merge_pr", "cleanup_branch"): res = subagent_gate.assess_subagent_delegation(task) self.assertTrue(res["block"], task) self.assertFalse(res["allowed"], task) self.assertEqual(res["task_class"], "deterministic_write", task) self.assertTrue( any("explicitly allowed" in r for r in res["reasons"]), task) # AC2: explicit allowance without a recorded justification still blocks def test_write_delegation_requires_recorded_justification(self): res = subagent_gate.assess_subagent_delegation( "create_pr", explicitly_allowed=True, inherited_context=_full_context()) self.assertTrue(res["block"]) self.assertTrue( any("justification" in r for r in res["reasons"])) # AC3: explicit allowance + justification but missing inherited context def test_write_delegation_blocks_on_missing_inherited_context(self): context = _full_context() del context["issue_lock"] del context["command_deny_list"] res = subagent_gate.assess_subagent_delegation( "commit", explicitly_allowed=True, justification="parent session proved batch commit needs isolation", inherited_context=context) self.assertTrue(res["block"]) self.assertIn("issue_lock", res["missing_context"]) self.assertIn("command_deny_list", res["missing_context"]) def test_write_delegation_blocks_on_empty_context_values(self): context = _full_context() context["worktree_path"] = " " res = subagent_gate.assess_subagent_delegation( "commit", explicitly_allowed=True, justification="isolation required", inherited_context=context) self.assertTrue(res["block"]) self.assertIn("worktree_path", res["missing_context"]) def test_write_delegation_allowed_with_authorization_and_full_context(self): res = subagent_gate.assess_subagent_delegation( "edit_code", explicitly_allowed=True, justification="parallel mechanical rename across many files", inherited_context=_full_context()) self.assertFalse(res["block"]) self.assertTrue(res["allowed"]) self.assertEqual(res["missing_context"], []) # Allowed read-only delegation needs no explicit authorization def test_read_only_delegation_allowed(self): for task in ("read_files", "code_search", "inventory_prs", "summarize_issue"): res = subagent_gate.assess_subagent_delegation(task) self.assertFalse(res["block"], task) self.assertTrue(res["allowed"], task) self.assertEqual(res["task_class"], "read_only", task) # Unknown task types fail closed def test_unknown_task_fails_closed(self): res = subagent_gate.assess_subagent_delegation("launch_missiles") self.assertTrue(res["block"]) self.assertEqual(res["task_class"], "unknown") def test_blank_task_fails_closed(self): res = subagent_gate.assess_subagent_delegation(" ") self.assertTrue(res["block"]) self.assertEqual(res["task_class"], "unknown") class TestValidateSubagentReport(unittest.TestCase): # AC4: subagent output must carry the same proof fields as the parent def test_full_report_valid(self): res = subagent_gate.validate_subagent_report(_full_report()) self.assertTrue(res["valid"]) self.assertFalse(res["block"]) self.assertEqual(res["missing_fields"], []) def test_missing_proof_fields_invalid(self): report = _full_report() del report["validation_results"] del report["worktree_path"] res = subagent_gate.validate_subagent_report(report) self.assertFalse(res["valid"]) self.assertTrue(res["block"]) self.assertIn("validation_results", res["missing_fields"]) self.assertIn("worktree_path", res["missing_fields"]) def test_empty_field_values_invalid(self): report = _full_report() report["changed_files"] = "" res = subagent_gate.validate_subagent_report(report) self.assertFalse(res["valid"]) self.assertIn("changed_files", res["missing_fields"]) def test_none_report_invalid(self): res = subagent_gate.validate_subagent_report(None) self.assertFalse(res["valid"]) self.assertTrue(res["block"]) class TestOperatorGuideRule(unittest.TestCase): def test_operator_guide_declares_subagent_rule(self): import gitea_mcp_server rule = gitea_mcp_server._GUIDE_RULES["subagent_delegation"].lower() for phrase in ("deterministic write", "inherit", "read-only", "fail closed"): self.assertIn(phrase, rule) if __name__ == "__main__": unittest.main()