feat: add fail-closed subagent delegation gates (#266)

Adds subagent_gate.py following the author_proofs/review_proofs pure-policy
pattern:

- assess_subagent_delegation: deterministic write tasks (issue claim,
  branch creation, code edits, commits, PR creation, review, merge,
  cleanup) are blocked for subagents unless explicitly allowed with a
  recorded justification and the full inherited gate context (issue lock,
  branch/worktree path, identity/profile, allowed tool class, command
  deny list, validation ledger requirement, final report schema);
  read-only delegation stays allowed; unknown task types fail closed.
- validate_subagent_report: subagent output is accepted only when it
  carries the same proof fields as a parent workflow final report.
- Operator guide: new subagent_delegation rule in _GUIDE_RULES so MCP
  clients discover the policy.
- tests/test_subagent_gate.py: 13 tests covering blocked write
  delegation, justification requirement, missing/blank inherited
  context, allowed read-only delegation, unknown tasks, and invalid
  subagent final reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 00:28:32 -04:00
co-authored by Claude Opus 4.8
parent d6f4f936e3
commit e09df4a348
3 changed files with 331 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
"""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,
}