feat: require strict signed task authorization before approval consumption
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 09:20:57 +02:00
parent ebca4f5adb
commit ff1a13dabd
5 changed files with 539 additions and 4 deletions

View file

@ -23,7 +23,7 @@ from secrets_engine.execution_contract import ExecutionContract, require_executi
from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim
from secrets_engine.catalog import human_control_required
from secrets_engine.decision_check import check_decision, require_supported_pdp_address
from secrets_engine.decision_signature import load_trust
from secrets_engine.decision_signature import load_trust, strict_json
from secrets_engine.authorization import (
build_action_request,
validate_decision_envelope,
@ -70,6 +70,8 @@ class AuthorizedAction:
binding: ConsumeBinding
decision_id: str
expires_at: str
# Detached original served artifacts for the narrow task-execution handoff.
artifacts: str = field(default="", repr=False, compare=False)
@property
def id(self) -> str:
@ -380,6 +382,8 @@ def authorize_action(
),
decision_id=validated.decision_id,
expires_at=validated.expires_at,
artifacts=json.dumps({"request":expected_request,"claim":observation.claim,"envelope":envelope},
sort_keys=True,separators=(",",":"),allow_nan=False),
)
@ -391,6 +395,7 @@ def consume_approval(
binding: ConsumeBinding,
timeout_seconds: float = 3,
opener: Callable[..., Any] = credential_urlopen,
strict_response: bool = False,
) -> ConsumedApproval:
"""POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use."""
if not base_url or not base_url.startswith(("http://", "https://")):
@ -438,8 +443,8 @@ def consume_approval(
if status != 200 or len(raw) > _MAX_BODY:
raise DecisionError(_status_message(status if status != 200 else 502))
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
payload = (strict_json if strict_response else json.loads)(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError, RecursionError) as exc:
raise DecisionError("approval consume returned invalid JSON") from exc
if not isinstance(payload, dict):
raise DecisionError("approval consume returned invalid payload")
@ -447,6 +452,19 @@ def consume_approval(
raise DecisionError("approval consumption was not confirmed")
if payload.get("request_digest") != binding.request_digest:
raise DecisionError("approval consume digest does not match the request")
if strict_response:
from datetime import datetime
if (payload.get("approval_id") != approval_id
or payload.get("decision_id") != binding.decision_id
or payload.get("idempotent") is not False
or any(key in payload for key in ("effect","allow","deny","decision"))):
raise DecisionError("strict task consumption correspondence refused")
try:
stamp = datetime.fromisoformat(payload["consumed_at"].replace("Z","+00:00"))
if stamp.tzinfo is None or stamp.utcoffset() is None:
raise ValueError("timezone required")
except (KeyError,TypeError,AttributeError,ValueError):
raise DecisionError("strict task consumption timestamp refused") from None
decision_id = payload.get("decision_id")
if decision_id is not None and (
not isinstance(decision_id, str) or not decision_id