← Files taskplaneARCHIVED FILE
taskplane/workflow_approval.py
22.6 KB · Oct 2, 2026 · 00:29 UTC
"""Run-bound, observed authorization for optional automatic phase decisions.
This is cooperative workflow policy, not host authentication or tool permission.
No policy executes commands, approves missing evidence, or extends a route.
"""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
import json
from pathlib import Path
import re
from typing import Any
from . import workflow as w
from .primitives import content_fingerprint
SCHEMA = "taskplane.approval-policy/v1"
ASSESSMENT = "taskplane.policy-assessment/v1"
ASSESSMENT_LIMIT = 64 * 1024
def read_assessment(workspace: Path, filename: str = "", inline: str | None = None) -> dict[str, Any]:
"""Accept a bounded control payload without permitting sealed workspace writes."""
from . import workflow_evidence as evidence
import os
import stat
w.require(bool(filename) != (inline is not None), "invalid_evidence",
"Supply exactly one assessment file or inline JSON object.")
if inline is not None:
raw = inline.encode("utf-8")
else:
target = evidence.path(workspace, filename)
try:
fd = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
w.require(stat.S_ISREG(info.st_mode) and info.st_size <= ASSESSMENT_LIMIT,
"invalid_evidence", "Assessment must be a regular file of at most 64 KiB.")
raw = stream.read(ASSESSMENT_LIMIT + 1)
except OSError as exc:
raise w.Refusal("invalid_evidence", f"Assessment unavailable: {filename}") from exc
w.require(len(raw) <= ASSESSMENT_LIMIT, "invalid_evidence", "Assessment exceeds 64 KiB.")
try:
value = json.loads(raw)
except (ValueError, UnicodeError):
raise w.Refusal("invalid_evidence", "Assessment must be a JSON object.") from None
w.require(isinstance(value, dict), "invalid_evidence", "Assessment must be a JSON object.")
return dict(value)
def policy_binding(state: dict[str, Any]) -> dict[str, Any]:
return {**{k: state[k] for k in ("workspace", "root", "run", "revision")},
"scope_digest": content_fingerprint(state["scope"])}
def _text(value: Any, maximum: int = 4096) -> bool:
return isinstance(value, str) and bool(value.strip()) and len(value) <= maximum
def decision_text(excerpt: str) -> str:
"""Remove quoted examples and normalize presentation, not user provenance."""
text = excerpt.casefold().replace("’", "'")
text = re.sub(r'```[\s\S]*?```|`[^`]*`|“[^”]*”|"[^"]*"|(?<!\w)\'[^\'\n]+\'(?!\w)', ' ', text)
return ' '.join(line.strip() for line in text.splitlines() if not line.lstrip().startswith('>'))
def decision_phase(excerpt: str) -> str | None:
phases = '|'.join(w.PHASES)
text = decision_text(excerpt)
match = re.search(r'\b(' + phases + r')\s+(?:phase\s+)?(?:is\s+)?(?:approved|accepted)\b', text)
if not match:
match = re.search(r'\b(?:approve|accept)\s+(?:the\s+)?(?:(?:this|current)\s+)?(' + phases + r')\b', text)
return match[1] if match else None
def conversational_choice(excerpt: str) -> str | None:
"""Recognize explicit everyday decisions; ambiguity requires clarification.
This only classifies an observed response. The adapter still verifies its
human source, the presented checkpoint, ordering and exact binding.
"""
text = re.sub(r'\s+', ' ', decision_text(excerpt)).strip()
qualifiers = re.sub(r'\s+', ' ', excerpt.casefold().replace("’", "'")).strip()
# Quotation cannot hide a qualification to an otherwise affirmative prefix.
if not text or '?' in qualifiers or re.search(
r"\b(?:if|unless|until|when|after|before|once|provided|assuming|hypothetically|example|would|might|maybe|perhaps|subject to|as long as)\b", qualifiers
):
return None
# A later qualification/negation must not be hidden by an affirmative prefix.
if re.search(r"\b(?:but|however|except|yet|no|not|never|don't|cannot|can't|shouldn't|without)\b", qualifiers):
return None
lead = re.sub(r'^(?:please\s+)?(?:i\s+)?', '', text).rstrip('.! ')
dissent = {
'cancelled': r'(?:cancel(?:led)?|stop|abort)',
'rejected': r'(?:reject(?:ed)?|decline(?:d)?)',
'changes_requested': r'(?:changes? requested|request changes|needs? (?:changes|revisions)|fix (?:the )?issues|revise)',
}
choices = {choice for choice, pattern in dissent.items()
if re.match(r'^' + pattern + r'\b', lead)}
phase = r'(?:' + '|'.join(w.PHASES) + r')'
approval = (r'^(?:(?:' + phase + r'\s+(?:phase\s+)?(?:is\s+)?)?'
r'(?:approv(?:e|ed)|accept(?:ed)?|apparoved|apprvoed)|'
r'(?:looks?|sounds?) (?:good|great)|lgtm|go ahead|proceed|continue|'
r'yes|yep|yeah|ok(?:ay)?|ship it)\b')
if re.match(approval, lead):
choices.add('approved')
# Every recognized dissent form also qualifies an affirmative prefix.
# Quoting that qualification cannot hide it from the mixed-decision check.
conflict = any(re.search(r'\b' + pattern + r'\b', qualifiers) for pattern in dissent.values())
if 'approved' in choices and conflict:
return None
# "Yes, explain ..." or "continue reviewing" is not acceptance of an output.
if 'approved' in choices and re.search(
r'\b(?:explain|review(?:ing)?|investigat\w*|research|discuss|consider|approved by|implement an? option|add an? (?:option|feature))\b', text
):
return None
return next(iter(choices)) if len(choices) == 1 else None
def affirmative_consent(excerpt: str) -> bool:
"""Recognize direct approval instructions, not arbitrary natural-language intent.
Quoted examples are not instructions. Unsupported or contradictory wording
stays manual; the recorder must still interpret and assess every condition.
"""
text = decision_text(excerpt)
text = re.sub(r'^\s*(?:\[@taskplane\]\(plugin://[^)]+\)|@taskplane)\s*', '', text)
if '?' in excerpt or re.search(r'\b(?:if|unless|until|when|once|provided|assuming|hypothetically|maybe|perhaps|subject to|as long as)\b', excerpt.casefold()):
return False
# A required check can qualify autonomy; a future human decision cannot.
# Inspect the whole instruction so an affirmative prefix cannot hide a later
# request to wait, including a separate sentence or quoted qualification.
decision_action = (r'(?:approv(?:e[sd]?|ing|al)|confirm(?:s|ed|ing|ation)?'
r'|authori[sz](?:e[sd]?|ing|ation)|consent(?:s|ed|ing)?)')
human_decision = (r'\b(?:(?:i|we|you|the (?:user|reviewer))\s+'
r'(?:(?:have|has|had|will|explicitly|manually)\s+)*' + decision_action +
r'|(?:my|our|your|human|user|manual|reviewer(?:\'s)?)\s+'
r'(?:approval|confirmation|authorization|consent))\b')
original = excerpt.casefold().replace("’", "'")
# A human decision can also be the subject of a requirement: "my approval
# is required" or "with my approval required first". Keep the predicate
# explicit so "my approval is not required" does not create a requirement.
human_requirement = (human_decision + r'\s+'
r'(?:(?:is|remains|will be|must be)\s+)?(?:still\s+)?'
r'(?:required|needed|necessary|mandatory)\b')
# Punctuation is not a reliable end to a qualification (for example Dr.,
# or a condition continued after a semicolon/newline). Scan the original
# instruction through its end before considering any affirmative prefix.
# Checks passing and named phase stops alone contain no decision action.
if (re.search(r'\b(?:after|before)\b[\s\S]*\b' + decision_action + r'\b', original)
or re.search(r'\b(?:wait|await|ask|pending)\b'
r'[\s\S]*\b' + decision_action + r'\b', original)
or re.search(r'\b(?:require|need|obtain|get)\b[\s\S]*' + human_decision, original)
or re.search(human_requirement, original)):
return False
clauses = [re.sub(r'\s+', ' ', clause).strip() for clause in re.split(r'[.;!]', text)]
approval_term = r'\b(?:auto[ -]?approv\w*|automatic\w*\s+(?:phase\s+)?approv\w*|autonomous)\b'
# Quoted contradictory instructions also need clarification, even though a
# quoted positive example can never supply authorization by itself.
for clause in re.split(r'[.;!]', excerpt.casefold().replace("’", "'")):
if re.search(r'\bmanual\s+(?:phase\s+)?approval\b', clause):
return False
if re.search(approval_term, clause) and (
re.search(r"\b(?:no|not|never|without|cannot|can't|don't|won't|isn't|aren't|example|hypothetical)\b", clause)
or re.search(r'\b(?:stop|disable|revoke|cancel)\b.{0,50}' + approval_term, clause)
):
return False
# Match an imperative (or explicit authorization) at a sentence boundary.
# Feature requests such as "implement an option to ..." cannot match.
prefix = r'^(?:now[, ]+)?(?:for this (?:task|run|release|workflow),?\s+)?(?:please\s+)?'
actor = r'(?:(?:i (?:explicitly )?authorize (?:you|taskplane) to|run autonomously and)\s+)?'
verb = r'(?:auto[ -]?approve|automatically approve)\s+'
target = r'(?:all\s+|the\s+|each\s+)?(?:phases?\b|phase transitions?\b|product\b|design\b|plan\b|build\b|evaluate\b|engineering\b|retro\b)'
direct = prefix + actor + verb + target
request = prefix + r'(?:(?:i (?:want|need|would like)(?: you)? to|you may)\s+)?'
workflow = (request + r'(?:start|run|execute|proceed with)\s+(?:an?\s+|the\s+|this\s+)?'
r'(?:(?:full|end[ -]to[ -]end)\s+)?(?:auto[ -]?approved|automatically approved|autonomous)\s+'
r'(?:full\s+)?(?:workflow|flow|run|delivery)\b')
automatic_phases = request + r'(?:run|execute)\s+(?:all\s+)?(?:release\s+)?phases\s+automatically\b'
end_to_end = (request + r'(?:use\s+[^.;!]{1,512}\s+as (?:an? )?input and\s+)?'
r'(?:start|run|execute|proceed with)\s+(?:an?\s+|the\s+)?(?:(?:full\s+)?end[ -]to[ -]end|full)\s+'
r'(?:flow|workflow|delivery)\s+with\s+auto[ -]?approv(?:al|e)\b')
return any(re.search(pattern, clause) is not None for clause in clauses
for pattern in (direct, workflow, automatic_phases, end_to_end))
def authorize(state: dict[str, Any], request: dict[str, Any]) -> dict[str, Any]:
w.require(state.get("profile") == "native_workflow", "unsupported_authority",
"Observed automatic authorization is available only in native_workflow.")
w.require(not state["finished"], "approval_required", "An ended run cannot change its policy.")
w.require(request.get("schema") == SCHEMA, "invalid_evidence", "Approval policy schema is missing.")
event = request.get("event_id")
w.require(_text(event, 512), "invalid_evidence", "Policy needs an actual user event reference.")
events = state.get("policy_events", {})
digest = content_fingerprint(request)
if event in events:
w.require(events[event] == digest, "stale_checkpoint", "Conflicting policy event replay.")
return deepcopy(state)
w.require(content_fingerprint(request.get("binding")) == content_fingerprint(policy_binding(state)), "stale_checkpoint",
"Policy must bind the current workspace, task, run, scope and revision.")
source = request.get("source")
w.require(isinstance(source, dict) and source.get("kind") in ("conversation", "native_prompt")
and source.get("conversation") == state["root"] and source.get("actor") == "user"
and source.get("automatic") is False and _text(source.get("reference"), 512)
and request.get("recorder") in ("root_orchestrator", "native_prompt_hook"),
"invalid_evidence", "Policy requires observed user provenance, not an agent/tool event.")
assert isinstance(source, dict)
try:
observed = datetime.fromisoformat(str(source.get("observed_at", "")).replace("Z", "+00:00"))
started = datetime.fromisoformat(state["started_at"].replace("Z", "+00:00"))
w.require(observed.tzinfo is not None and started.tzinfo is not None
and observed <= datetime.now(observed.tzinfo), "invalid_evidence", "Invalid authorization time.")
# Instructions can precede start, but cannot come from an older run.
w.require(request.get("request_reference") == state.get("request_provenance", {}).get("reference")
or observed >= started, "invalid_evidence", "Pre-start authorization must reference this run's request.")
except (ValueError, TypeError):
raise w.Refusal("invalid_evidence", "Policy needs a valid observed timestamp.") from None
excerpt = request.get("excerpt")
w.require(_text(excerpt), "invalid_evidence", "Preserve the actual additional instructions.")
assert isinstance(excerpt, str)
mode = request.get("mode")
w.require(mode in ("manual", "autonomous"), "invalid_evidence", "Choose manual or autonomous approval.")
normalized = excerpt.casefold()
if mode == "autonomous":
w.require(affirmative_consent(excerpt), "approval_required",
"Automatic approval intent is unclear. Ask whether the user wants automatic phase approvals for this run, and preserve their answer in their own words. Negative, quoted or feature-only wording stays manual.")
else:
w.require(re.search(r"manual|(?:stop|disable|revoke|cancel).*(?:auto|automatic)", normalized),
"approval_required", "Preserve an explicit instruction to stop or return to manual approval.")
allowed, stops = request.get("allowed_phases", []), request.get("stop_phases", [])
w.require(isinstance(allowed, list) and isinstance(stops, list)
and all(isinstance(p, str) and p in w.PHASES for p in allowed + stops)
and len(set(allowed)) == len(allowed) and len(set(stops)) == len(stops)
and (bool(allowed) if mode == "autonomous" else not allowed),
"invalid_evidence", "Declare exact allowed phases and mandatory stop phases.")
conditions = request.get("conditions", [])
w.require(isinstance(conditions, list) and len(conditions) <= 32, "invalid_evidence", "Invalid policy conditions.")
ids = {"user_instructions"}
for condition in conditions:
w.require(isinstance(condition, dict) and _text(condition.get("id"), 80)
and condition["id"] not in ids and condition.get("kind") in ("observed", "required_check")
and _text(condition.get("instruction"))
and (condition["kind"] != "required_check" or _text(condition.get("check"), 200)),
"invalid_evidence", "Conditions need unique IDs, instructions and a supported kind.")
ids.add(condition["id"])
s = deepcopy(state)
history = s.setdefault("policy_history", [])
w.require(len(history) < 256, "state_unavailable", "Policy history limit reached; do not reset active state.")
policy = {"schema": SCHEMA, "id": event, "revision": len(history) + 1, "mode": mode,
"binding": policy_binding(state), "authorized_scope": deepcopy(state["scope"]),
"allowed_phases": allowed, "stop_phases": stops,
"conditions": [{"id": "user_instructions", "kind": "observed", "instruction": excerpt}] + conditions,
"provenance": {"source": deepcopy(source), "recorder": request["recorder"], "excerpt": excerpt},
"assurance": "observed", "request_digest": digest}
policy["digest"] = content_fingerprint(policy)
history.append(policy)
s["approval_policy"] = deepcopy(policy)
s["policy_suspension"] = None
s.setdefault("policy_events", {})[event] = digest
s["revision"] += 1
return s
def suspend(state: dict[str, Any], reason: str) -> None:
if state.get("approval_policy", {}).get("mode") == "autonomous":
state["policy_suspension"] = reason
def validate_history(state: dict[str, Any]) -> None:
history = state.get("policy_history", [])
w.require(isinstance(history, list) and len(history) <= 256, "state_unavailable", "Invalid policy history.")
for index, policy in enumerate(history):
w.require(isinstance(policy, dict) and policy.get("schema") == SCHEMA
and policy.get("revision") == index + 1 and policy.get("assurance") == "observed"
and policy.get("digest") == content_fingerprint({k:v for k,v in policy.items() if k != "digest"})
and all(policy.get("binding", {}).get(k) == state[k] for k in ("workspace", "root", "run")),
"state_unavailable", "Corrupt or foreign policy history.")
if history:
w.require(state.get("approval_policy") == history[-1], "state_unavailable", "Policy version mismatch.")
else:
w.require(not state.get("approval_policy"), "state_unavailable", "Policy has no history.")
def decision_authorized(state: dict[str, Any], decision: dict[str, Any]) -> bool:
if decision.get("human") is True and decision.get("automatic") is False:
return bool(decision.get("kind", "human") == "human")
if state.get("profile") != "native_workflow" or decision.get("kind") != "policy":
return False
policy = next((p for p in state.get("policy_history", []) if p["digest"] == decision.get("policy_digest")), None)
assessment = decision.get("assessment", {})
stage = next((v for v in state["visits"] if v["id"] == decision.get("binding", {}).get("visit")), None)
return bool(policy and stage and policy["mode"] == "autonomous"
and stage["phase"] in policy["allowed_phases"] and stage["phase"] not in policy["stop_phases"]
and decision.get("human") is False and decision.get("automatic") is True
and decision.get("choice") == "approved" and assessment.get("binding") == decision.get("binding")
and assessment.get("policy_digest") == policy["digest"]
and {c["id"] for c in assessment.get("conditions", [])} == {c["id"] for c in policy["conditions"]}
and all(c.get("status") == "pass" for c in assessment.get("conditions", [])))
def automatic_decision(state: dict[str, Any], assessment: dict[str, Any]) -> dict[str, Any]:
w.require(state.get("profile") == "native_workflow", "unsupported_authority", "Automatic approval is unavailable in protected_host.")
w.require(assessment.get("schema") == ASSESSMENT and isinstance(assessment.get("binding"), dict),
"invalid_evidence", "Supply a checkpoint-bound policy assessment.")
event = "policy:" + str(assessment.get("policy_digest")) + ":" + str(assessment["binding"].get("checkpoint"))
if event in state["decisions"]:
old = state["decisions"][event]
w.require(old.get("assessment") == assessment, "stale_checkpoint", "Conflicting automatic decision replay.")
return deepcopy(old)
policy = state.get("approval_policy") or {}
w.require(policy.get("mode") == "autonomous" and not state.get("policy_suspension"),
"approval_required", state.get("policy_suspension") or "Manual approval is active; explicit authorization is required.")
stage = w.current(state)
w.require(stage["decision"] == "awaiting_human_approval" and stage["packet"], "approval_required", "Submit evidence before automatic approval.")
w.require(stage["phase"] in policy["allowed_phases"] and stage["phase"] not in policy["stop_phases"],
"approval_required", "This phase requires a human checkpoint under the current policy.")
packet = stage["packet"]
w.require(content_fingerprint(assessment["binding"]) == content_fingerprint(w.binding(state, packet)) and assessment.get("policy_digest") == policy["digest"],
"stale_checkpoint", "Assessment belongs to an old policy or checkpoint.")
outer = policy["authorized_scope"]
w.require(state["scope"]["criteria"] == outer["criteria"]
and state["scope"].get("verification_inputs", []) == outer.get("verification_inputs", [])
and all(set(paths) <= set(outer["paths"][phase]) for phase,paths in state["scope"]["paths"].items())
and not packet.get("route_change"), "approval_required", "Scope or route changes require renewed human authorization.")
output = packet["output"]
checks = output.get("build_checks", [])
if stage["phase"] == "build":
w.require(checks and all(c.get("status") == "pass" for c in checks) and not output.get("known_gaps"),
"approval_required", "Build checks or unresolved gaps require review.")
if stage["phase"] == "evaluate":
w.require(all(c.get("status") == "pass" for c in output["criterion_results"].values())
and not output.get("unknowns_and_failures"), "approval_required", "Evaluation is failed or unknown.")
if stage["phase"] == "engineering":
w.require(not any(f.get("blocking") or str(f.get("severity", "")).casefold() in
("p0", "p1", "critical", "high", "blocker") for f in output.get("findings", [])),
"approval_required", "Unresolved Engineering blockers require review.")
items = assessment.get("conditions")
w.require(isinstance(items, list) and len(items) == len(policy["conditions"])
and all(isinstance(c, dict) for c in items)
and {c.get("id") for c in items} == {c["id"] for c in policy["conditions"]},
"invalid_evidence", "Assess every user condition, including the original instructions.")
assert isinstance(items, list)
for c in items:
w.require(c.get("status") == "pass", "approval_required", "A required condition is failed or unknown: " + str(c.get("id")))
refs = c.get("evidence")
w.require(_text(c.get("explanation")) and isinstance(refs, list) and refs
and all(isinstance(p, str) and p in packet["manifest"] for p in refs),
"invalid_evidence", "Condition assessment needs an explanation and sealed evidence files.")
rule = next(rule for rule in policy["conditions"] if rule["id"] == c["id"])
if rule["kind"] == "required_check":
w.require(any(check.get("name") == rule["check"] and check.get("status") == "pass" for check in checks),
"approval_required", "Named required check is absent or not passing.")
return {"event_id": event, "kind": "policy", "human": False, "automatic": True,
"choice": "approved", "binding": w.binding(state, packet), "policy_digest": policy["digest"],
"policy_id": policy["id"], "policy_revision": policy["revision"], "assurance": "observed",
"provenance": deepcopy(policy["provenance"]), "assessment": deepcopy(assessment)}
SHA-256: d51a2c03ae78aebac53488591c8ea3fabdf6cdee7aec047df7033fa9ddb592ef