"""Fail-closed subagent delegation gates (#266). Subagents can bypass or lose context for worktree, capability, mutation, retry, and reporting rules. These helpers make delegation an explicit, provable decision instead of a default: deterministic write tasks stay inline unless the parent session records why a subagent is needed and hands the subagent the full gate context it must operate under. Like ``author_proofs``/``review_proofs``, the helpers are pure (no git, no API calls): the parent workflow gathers the facts and passes them in, so the same logic works from prompts, harness assertions, and tests. Nothing here weakens the review/merge/permission gates — a delegated subagent is subject to the same gates as its parent. """ # AC1: deterministic write workflows a subagent must never run by default. DETERMINISTIC_WRITE_TASKS = frozenset({ "claim_issue", "create_branch", "edit_code", "commit", "push_branch", "create_pr", "review_pr", "merge_pr", "cleanup_branch", "close_issue", }) # Read-only delegation that needs no explicit authorization. READ_ONLY_TASKS = frozenset({ "read_files", "code_search", "inventory_prs", "summarize_issue", "explore_codebase", }) # AC3: context a subagent must inherit from the parent session before any # authorized write delegation may proceed. REQUIRED_INHERITED_CONTEXT = ( "issue_lock", "branch", "worktree_path", "identity_profile", "allowed_tool_class", "command_deny_list", "validation_ledger_requirement", "final_report_schema", ) # AC4: proof fields a subagent final report must carry — the same fields a # parent workflow's final report requires. REQUIRED_SUBAGENT_REPORT_FIELDS = ( "identity_profile", "worktree_path", "branch", "changed_files", "validation_results", "workspace_mutations", ) def _clean(value): return (value or "").strip() if isinstance(value, str) else value def _classify_task(task_type): task = _clean(task_type) if not task: return "", "unknown" if task in DETERMINISTIC_WRITE_TASKS: return task, "deterministic_write" if task in READ_ONLY_TASKS: return task, "read_only" return task, "unknown" def _missing_context_fields(inherited_context): context = inherited_context or {} missing = [] for field in REQUIRED_INHERITED_CONTEXT: value = context.get(field) if not isinstance(value, str) or not value.strip(): missing.append(field) return missing def assess_subagent_delegation(task_type, *, explicitly_allowed=False, justification=None, inherited_context=None): """Decide whether delegating *task_type* to a subagent may proceed. Fail closed: unknown tasks are blocked; deterministic write tasks are blocked unless explicitly allowed (AC1) with a recorded justification (AC2) and the full inherited gate context (AC3). Read-only delegation is allowed without explicit authorization. Returns {'block', 'allowed', 'task_type', 'task_class', 'reasons', 'missing_context'}. """ task, task_class = _classify_task(task_type) reasons = [] missing_context = [] if task_class == "unknown": reasons.append( f"task type '{task}' is not a recognized delegation class; " "run it inline in the parent session (fail closed)" ) elif task_class == "deterministic_write": if not explicitly_allowed: reasons.append( f"deterministic write task '{task}' must run inline unless " "subagent use is explicitly allowed by the parent session" ) if not _clean(justification): reasons.append( "no recorded justification for why a subagent is needed; " "the parent session must record one before delegating" ) missing_context = _missing_context_fields(inherited_context) if missing_context: reasons.append( "subagent would not inherit required gate context: " + ", ".join(missing_context) ) block = bool(reasons) return { "block": block, "allowed": not block, "task_type": task, "task_class": task_class, "reasons": reasons, "missing_context": missing_context, } def validate_subagent_report(report_fields): """AC4: accept subagent output only with the parent-grade proof fields. *report_fields* maps field name -> reported value. Missing or blank proof fields make the report invalid (fail closed). Returns {'valid', 'block', 'reasons', 'missing_fields'}. """ reasons = [] report = report_fields or {} missing = [] for field in REQUIRED_SUBAGENT_REPORT_FIELDS: value = report.get(field) if not isinstance(value, str) or not value.strip(): missing.append(field) if missing: reasons.append( "subagent final report missing required proof fields: " + ", ".join(missing) ) valid = not reasons return { "valid": valid, "block": not valid, "reasons": reasons, "missing_fields": missing, }