Require declared human control in factory credential delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
d1c13b5dd6
commit
2b0d04e8e1
11 changed files with 451 additions and 27 deletions
|
|
@ -41,6 +41,7 @@ delivery_auth:
|
|||
token_num_uses: 8
|
||||
approval:
|
||||
model: ccr
|
||||
human_control: true
|
||||
decision_ref: CCR-2026-0016
|
||||
notes: Custody only has been completed. Native apply and exec require durable exact-action
|
||||
authorization, engine consume, scoped backend authority and verified delivery
|
||||
|
|
|
|||
|
|
@ -86,9 +86,50 @@ authority is approval-engine (checked as `issuer`); the decision's is flex-auth.
|
|||
|
||||
Every live privileged production handler passes `_require_lane_approval`,
|
||||
which calls `require_production_consume` before `OpenBaoClient.resolve`.
|
||||
Dry-run and `plan` do not consume. Build/test remain fail-open relative to
|
||||
approval-engine. The three-factor unsafe-demo exception is not a consume
|
||||
path.
|
||||
Dry-run and `plan` do not consume. For undeclared lanes, build/test and the
|
||||
three-factor unsafe-demo exception retain their existing behavior. An explicitly
|
||||
declared human control requires observation, PDP ALLOW and CAS consumption at
|
||||
every stage; no demo or lane-review fallback discharges it.
|
||||
|
||||
## Declared human-control workflow
|
||||
|
||||
The factory lane explicitly declares `approval.human_control: true`. This
|
||||
implements its existing human action-review requirement and GH-DEC-2026-016;
|
||||
it does not infer human control from a CCR model, stage, actor, or approver entry,
|
||||
and does not make a local authorization decision. Other catalog entries default
|
||||
to undeclared. The catalog value must be a boolean, and `bootstrap-only` cannot
|
||||
be combined with a declared human control.
|
||||
|
||||
The generated CheckRequest includes `context.human_control: true`. This changes
|
||||
its evaluator-bound identity. The separate request-creation caller must issue an
|
||||
unapproved Approval Engine object with **top-level `human_control: true`** and
|
||||
`pdp_path: true`, using the actual evaluator-origin digest of that exact claim-free
|
||||
request. A human subsequently supplies `/entries` through the admitted approver
|
||||
surface. Do not copy an old request digest or retrofit an existing approval.
|
||||
Approval Engine retains its unchanged five-field native act digest; the consumer
|
||||
joins through the distinct evaluator-origin PDP digest as before.
|
||||
|
||||
At observation, this engine requires **`claim.binding.human_control is true`**
|
||||
in addition to current validity, freshness and the PDP path. Missing, false,
|
||||
string or numeric declarations refuse before the PDP call. The declaration is
|
||||
checked again after PDP validation and carried in the internal consume binding
|
||||
and non-secret `approval_human_control` evidence. A declared lane cannot skip
|
||||
consumption through a build/test or unsafe-demo fallback. The consumer does not
|
||||
reconstruct approver identities or counts: Approval Engine owns that evidence.
|
||||
|
||||
The repeatable `tools/exercise_approval_identity.py --exec-owner --human-control`
|
||||
exercise creates a declared request through the actual engine API, proves a real
|
||||
KeyCape service JWT cannot bind it, proves an undeclared claim cannot reach the
|
||||
PDP/consume/backend path, and proves the real evaluator preserves the requirement.
|
||||
The positive human entry is **seeded in the disposable store**, while requester,
|
||||
claim and consume use real local service authentication. The backend and child
|
||||
payload are synthetic; this is not human JWT/PKCE or live custody evidence.
|
||||
See [the component receipt](evidence/2026-09-10-human-control-consumer-exercise.json).
|
||||
|
||||
Native narrow requester identity, browser human approval, admitted custody and
|
||||
current deployed services remain APPROVAL-WP-0002-T01/T03/T05,
|
||||
INFD-WP-0001-T07/T08 and SECRETS-WP-0009-T03. The factory catalog's exec owner
|
||||
remains pending; adding this requirement grants no runtime or credential access.
|
||||
|
||||
## Fail closed
|
||||
|
||||
|
|
@ -110,8 +151,9 @@ digest, idempotence, and consumed-at only. No token, secret, or accessor.
|
|||
|
||||
## Required configuration
|
||||
|
||||
The join is absent by default, so an unconfigured engine behaves exactly as it
|
||||
did before. Production additionally needs:
|
||||
The join is absent by default. Undeclared lanes retain their existing behavior;
|
||||
a lane declaring `approval.human_control: true` refuses an absent join.
|
||||
Production and declared human-control paths need:
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
|
|
|
|||
|
|
@ -127,3 +127,16 @@ Before accepting an existing production lane:
|
|||
lanes. Set `false` when the consumer needs only the KV data GET; generated
|
||||
policy omits the metadata endpoint. Owner metadata verification then needs
|
||||
separate operator authority, not broader consumer access.
|
||||
|
||||
## Explicit human-control requirement
|
||||
|
||||
`approval.human_control` is an optional boolean, defaulting to undeclared. Set it
|
||||
to `true` only for a workflow whose existing contract requires human review. It
|
||||
is not inferred from `approval.model`, stage or principal type. A declared lane
|
||||
cannot use `bootstrap-only`; its live action requires an Approval Engine claim
|
||||
explicitly declared human-controlled, current PDP ALLOW and CAS consumption.
|
||||
Missing services cannot fall back to ordinary lane review or an unsafe demo.
|
||||
See [the requester and consumer contract](approval-consumption.md#declared-human-control-workflow).
|
||||
The HelixForge native-delivery catalog declares this requirement and remains
|
||||
pending owner admission. No OpenBao policy, role, credential or readiness state
|
||||
is activated by this source declaration.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"target": "disposable local processes; synthetic credentials",
|
||||
"started_at": "2026-09-10T18:15:35.247006+00:00",
|
||||
"keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611",
|
||||
"approval_engine_commit": "c8f85c6d7691f2319ad462791e6d385515d7e70d",
|
||||
"flex_auth_commit": "88b354377c8e26b162f1234e673072f1c06dcd89",
|
||||
"keycape_contract_commit": "f9812ab3b2bfe8f0817185f44071e612264ec3ee",
|
||||
"consumer_source_sha256": {
|
||||
"approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5",
|
||||
"approval_consume.py": "4515c74dbe1cea030cdccebf17b27642ad4f67f2aaee4f3a87e4bb2b9cfad295",
|
||||
"approval_claim.py": "20b728d2d3e14ffc89fa21a137716015875f9a05c6af7ff48d07cf48d89503a9",
|
||||
"authorization.py": "c2b6679b4235611c4fd39dad8964a522378df42c0658f0a02dcc31f05141d6a0",
|
||||
"config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955",
|
||||
"service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c",
|
||||
"cli.py": "1147d0e60850cfc38ee21e8564f51656d364ddc3cd8d6353a0c2dd6a0b78b8ec",
|
||||
"catalog.py": "247c29314bcf86b3439d756ed911df62fb43c3c498b18ce5403704986a501b18",
|
||||
"exec_owner.py": "10c21e7e37dd3d0aad41c4bb15df8f4a69c22d9046105a981a9613a91cd4c145",
|
||||
"exec_delivery.py": "b142b60d875acf3dea1ba3ae99182a7ed7328d955dee5be2d12c977fc5646e8e",
|
||||
"routing.py": "9d5050d7494a207394ce8251d8aac574ca0c08e3252639b5f05b373d6e35a076"
|
||||
},
|
||||
"limitations": [
|
||||
"standalone Flex Auth source, not deployed pin",
|
||||
"local Approval Engine source, not deployed image",
|
||||
"no live custody or client-side read grant",
|
||||
"no OpenBao effect or model execution",
|
||||
"positive human entry seeded in disposable store; no human JWT/PKCE proof"
|
||||
],
|
||||
"checks": {
|
||||
"operator_issued_and_approved_via_verified_jwt": true,
|
||||
"wrong_action_refused_before_consume": true,
|
||||
"producer_origin_join_with_carried_claim": true,
|
||||
"actual_consumer_claim_check_consume": true,
|
||||
"same_digest_retry_idempotent": true,
|
||||
"different_digest_refused": true,
|
||||
"spent_claim_refused": true,
|
||||
"operator_consume_scope_denied_by_issuer": true,
|
||||
"wrong_secret_refused": true,
|
||||
"no_access_token_file_created": true,
|
||||
"registry_override_accepts_exact_submission": true,
|
||||
"same_enriched_result_different_submission_refused": true,
|
||||
"real_dual_control_denial_then_claim_check_consume": true,
|
||||
"declared_control_refuses_real_keycape_service_bind": true,
|
||||
"undeclared_control_refused_before_real_pdp_consume_backend": true,
|
||||
"exec_owner_substitution_refused_before_consume_backend": true,
|
||||
"exec_owner_changed_environment_replay_refused_by_real_pdp_join": true,
|
||||
"exec_owner_real_cli_consumes_before_fixture_backend": true,
|
||||
"exec_owner_actual_child_excludes_parent_credentials": true,
|
||||
"exec_owner_digest_preserved_by_real_evaluator": true,
|
||||
"declared_human_intent_preserved_by_real_evaluator": true,
|
||||
"declared_human_fixture_consumed_before_owner_delivery": true
|
||||
},
|
||||
"flex_auth_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa",
|
||||
"producer_input_sha256": {
|
||||
"policy_package.md": "657fa9312c9dcabee059ec31c89ee13396fe9aa54d852edc9e6559a74bbc139a",
|
||||
"registry_snapshot.json": "37fba44ec0e0fd9b1e17a05ad1b7e4f9bb6ec9e0fc0bb3133e8af2ac6f832d7c"
|
||||
},
|
||||
"exec_owner_scope": "Synthetic recipient/backend with real KeyCape, Approval Engine, Flex Auth and Secrets Engine CLI; not native custody or human approval proof",
|
||||
"status": "passed",
|
||||
"cleanup_complete": true,
|
||||
"finished_at": "2026-09-10T18:15:47.092615+00:00",
|
||||
"consumer_base_commit": "d1c13b5dd667bcad453c94564f65bdf5f172ba3b",
|
||||
"source_suite": {
|
||||
"passed": 388,
|
||||
"new_cases": 27,
|
||||
"first_25_cases_failed_before": 25,
|
||||
"log_sha256": "e676747dc0f2547e42bf0f3d7f97a3e1a6da4d413428b44afe28d725abd7a4c7"
|
||||
},
|
||||
"factory_attempts": 0,
|
||||
"paid_model_calls": 0,
|
||||
"native_human_approval_proof": false,
|
||||
"remaining_records": [
|
||||
"SECRETS-WP-0009-T03",
|
||||
"APPROVAL-WP-0002-T01/T03/T05",
|
||||
"INFD-WP-0001-T07/T08",
|
||||
"RPF-WP-0035-T06 / CCR-2026-0019",
|
||||
"HFACT-WP-0001-T01/T03/T04/T05"
|
||||
],
|
||||
"command": "python tools/exercise_approval_identity.py --keycape-source PATH --approval-engine-source PATH --flex-auth-source PATH --exec-owner --human-control --receipt NEW_PATH"
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ def _validate_observation(
|
|||
claim: object,
|
||||
*,
|
||||
approval_id: str,
|
||||
require_human_control: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate issuer, shape, validity and freshness; no action correspondence."""
|
||||
|
|
@ -135,6 +136,8 @@ def _validate_observation(
|
|||
binding = claim.get("binding")
|
||||
if not isinstance(binding, dict):
|
||||
raise DecisionError("approval claim binding must be an object")
|
||||
if require_human_control and binding.get("human_control") is not True:
|
||||
raise DecisionError("approval claim does not declare binding.human_control true; request a human-controlled approval at issue")
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
freshness = claim.get("freshness")
|
||||
if not isinstance(freshness, dict):
|
||||
|
|
@ -154,13 +157,15 @@ def _validate_observation(
|
|||
|
||||
|
||||
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
||||
require_human_control: bool = False,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Validate a fresh fact for submission to the PDP, not authority to consume.
|
||||
|
||||
Action correspondence cannot be established until the evaluator returns its
|
||||
enriched approval binding. This function deliberately makes no such claim.
|
||||
"""
|
||||
observed = _validate_observation(claim, approval_id=approval_id, now=now)
|
||||
observed = _validate_observation(claim, approval_id=approval_id,
|
||||
require_human_control=require_human_control, now=now)
|
||||
binding = observed["binding"]
|
||||
if binding.get("pdp_path") is not True:
|
||||
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
|
||||
|
|
@ -172,15 +177,18 @@ def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
|||
|
||||
def validate_approval_claim(claim: object, *, approval_id: str,
|
||||
expected_binding_digest: str = "", expected_pdp_digest: str = "",
|
||||
require_human_control: bool = False,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
"""Validate the fact and compare an independently supplied binding.
|
||||
|
||||
A PDP digest supplied here must come from the evaluated decision, never a
|
||||
local reconstruction of the unenriched request.
|
||||
"""
|
||||
observed = _validate_observation(claim, approval_id=approval_id, now=now)
|
||||
observed = _validate_observation(claim, approval_id=approval_id,
|
||||
require_human_control=require_human_control, now=now)
|
||||
if expected_pdp_digest:
|
||||
observe_pdp_approval_claim(observed, approval_id=approval_id, now=now)
|
||||
observe_pdp_approval_claim(observed, approval_id=approval_id,
|
||||
require_human_control=require_human_control, now=now)
|
||||
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
|
||||
raise DecisionError("approval claim pdp digest does not match the request")
|
||||
elif expected_binding_digest:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from urllib.request import Request, urlopen
|
|||
|
||||
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
|
||||
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
|
||||
from secrets_engine.authorization import (
|
||||
build_action_request,
|
||||
|
|
@ -40,6 +41,7 @@ class ConsumeBinding:
|
|||
approval_id: str
|
||||
request_digest: str
|
||||
decision_id: str = ""
|
||||
human_control: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -68,6 +70,7 @@ class AuthorizedAction:
|
|||
"authorization_decision_id": self.decision_id,
|
||||
"authorization_expires_at": self.expires_at,
|
||||
"request_digest": self.binding.request_digest,
|
||||
"approval_human_control": self.binding.human_control,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -222,13 +225,17 @@ def resolve_approval_observation(
|
|||
) -> ApprovalObservation | None:
|
||||
"""Observe a fresh fact and prepare the claim-bearing request (step 1).
|
||||
|
||||
Missing serving coordinates return None, preserving the production refusal.
|
||||
Missing serving coordinates return None for undeclared lanes. A declared
|
||||
human control raises instead, so no stage/demo fallback can discharge it.
|
||||
Correspondence and a consume binding require authorize_action's PDP step.
|
||||
"""
|
||||
base_url = str(getattr(cfg, "approval_url", "") or "")
|
||||
auth_configured = approval_auth_configured(cfg)
|
||||
authorization_id = _authorization_id(entry, decision)
|
||||
required_human_control = human_control_required(entry)
|
||||
if not base_url or not auth_configured or not authorization_id:
|
||||
if required_human_control:
|
||||
raise DecisionError("human_control requires a served approval claim and exact action binding")
|
||||
return None
|
||||
|
||||
expected_request = _expected_request(
|
||||
|
|
@ -242,7 +249,8 @@ def resolve_approval_observation(
|
|||
authorization_id=authorization_id,
|
||||
opener=opener or credential_urlopen,
|
||||
)
|
||||
observe_pdp_approval_claim(claim, approval_id=authorization_id)
|
||||
observe_pdp_approval_claim(claim, approval_id=authorization_id,
|
||||
require_human_control=required_human_control)
|
||||
expected_request["context"]["approval"] = claim
|
||||
return ApprovalObservation(authorization_id, expected_request, claim)
|
||||
|
||||
|
|
@ -310,6 +318,7 @@ def authorize_action(
|
|||
validate_approval_claim(
|
||||
observation.claim, approval_id=observation.approval_id,
|
||||
expected_pdp_digest=envelope["binding"]["approval_binding_digest"],
|
||||
require_human_control=expected_request["context"].get("human_control") is True,
|
||||
)
|
||||
if validated.action != action:
|
||||
raise DecisionError("access-engine decision does not bind this action")
|
||||
|
|
@ -318,6 +327,7 @@ def authorize_action(
|
|||
approval_id=observation.approval_id,
|
||||
request_digest=validated.request_digest,
|
||||
decision_id=validated.decision_id,
|
||||
human_control=observation.claim["binding"].get("human_control") is True,
|
||||
),
|
||||
decision_id=validated.decision_id,
|
||||
expires_at=validated.expires_at,
|
||||
|
|
@ -413,16 +423,21 @@ def require_production_consume(
|
|||
evidence: Any = None,
|
||||
opener: Callable[..., Any] | None = None,
|
||||
) -> ConsumedApproval | None:
|
||||
"""CAS-consume before a production OpenBao call. No-op off the prod path.
|
||||
"""CAS-consume before production or an explicitly human-controlled action.
|
||||
|
||||
Build/test remain fail-open relative to approval-engine. The three-factor
|
||||
unsafe-demo exception is not a consume path. Missing binding, URL, or
|
||||
credential fail closed so a stance bypass cannot reach OpenBao.
|
||||
Undeclared build/test and the unsafe-demo exception retain their existing
|
||||
behavior. Declared human controls require a verified binding and consume
|
||||
regardless of stage/demo; missing inputs cannot fall back to a lane review.
|
||||
"""
|
||||
if getattr(entry, "stage", "") != "prod":
|
||||
return None
|
||||
if demo_exception_enabled(cfg):
|
||||
return None
|
||||
required_human_control = human_control_required(entry)
|
||||
if required_human_control:
|
||||
if binding is None or binding.human_control is not True:
|
||||
raise DecisionError("human_control requires an observed declared-human consume binding")
|
||||
else:
|
||||
if getattr(entry, "stage", "") != "prod":
|
||||
return None
|
||||
if demo_exception_enabled(cfg):
|
||||
return None
|
||||
if binding is None:
|
||||
raise DecisionError(
|
||||
"production OpenBao call requires CAS consume of an approval "
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from dataclasses import dataclass
|
|||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from secrets_engine.catalog import CatalogEntry
|
||||
from secrets_engine.catalog import CatalogEntry, human_control_required
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
|
@ -105,6 +105,10 @@ def build_action_request(
|
|||
"context": {"purpose": purpose},
|
||||
}
|
||||
)
|
||||
if human_control_required(entry):
|
||||
# Bind the requested control to the exact PDP submission. This is a
|
||||
# requirement on the observed approval, not an authorization verdict.
|
||||
request["context"]["human_control"] = True
|
||||
if action == "exec":
|
||||
from secrets_engine.exec_owner import owner_digest
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,15 @@ class CatalogEntry:
|
|||
return self.kind == "kv"
|
||||
|
||||
|
||||
def human_control_required(entry: Any) -> bool:
|
||||
"""Declared workflow requirement; never inferred from stage or approvers."""
|
||||
approval = getattr(entry, "approval", {})
|
||||
value = approval.get("human_control", False)
|
||||
if not isinstance(value, bool):
|
||||
raise CatalogError("approval.human_control must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> CatalogEntry:
|
||||
"""Validate a raw mapping and return a CatalogEntry, or raise CatalogError."""
|
||||
if not isinstance(data, dict):
|
||||
|
|
@ -339,6 +348,12 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
|||
f"allowed {VALID_APPROVAL_MODELS}"
|
||||
)
|
||||
|
||||
human_control = approval.get("human_control", False)
|
||||
if not isinstance(human_control, bool):
|
||||
raise CatalogError(f"{source}: approval.human_control must be a boolean")
|
||||
if human_control and approval["model"] == "bootstrap-only":
|
||||
raise CatalogError(f"{source}: human_control cannot use bootstrap-only approval")
|
||||
|
||||
risk = data.get("risk", {})
|
||||
if not isinstance(risk, dict):
|
||||
raise CatalogError(f"{source}: risk must be a mapping")
|
||||
|
|
|
|||
170
tests/test_human_control.py
Normal file
170
tests/test_human_control.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""A declared human-control lane cannot use an undeclared approval fact."""
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine import cli
|
||||
from secrets_engine.approval_consume import (
|
||||
ConsumeBinding, authorize_action, require_production_consume,
|
||||
)
|
||||
from secrets_engine.authorization import build_action_request, request_digest
|
||||
from secrets_engine.catalog import load_entry, validate_entry
|
||||
from secrets_engine.errors import CatalogError, DecisionError
|
||||
from tests.authorization_stub import AuthorizationStub
|
||||
from tests.test_catalog import VALID
|
||||
from tests.test_consume_binding_join import (
|
||||
AUTH_ID, _Cfg, _entry, _opener, _resolve, _served, _token,
|
||||
)
|
||||
from tests.test_exec_owner import bound
|
||||
from tests.test_privileged_cli_evidence import _config, _records
|
||||
|
||||
|
||||
def controlled_entry():
|
||||
entry = _entry()
|
||||
entry.approval.update(model="decision", human_control=True)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, 0, 1, "true", "false", [], {}])
|
||||
def test_catalog_declaration_is_strictly_boolean(value):
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw["approval"].update(model="decision", human_control=value)
|
||||
with pytest.raises(CatalogError, match="human_control"):
|
||||
validate_entry(raw)
|
||||
|
||||
|
||||
def test_bootstrap_only_cannot_declare_a_human_control():
|
||||
raw = copy.deepcopy(VALID)
|
||||
raw["approval"]["human_control"] = True
|
||||
with pytest.raises(CatalogError, match="human_control"):
|
||||
validate_entry(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["missing", False, None, 1, "true", [], {}])
|
||||
def test_undeclared_or_malformed_claim_refuses_before_pdp(tmp_path, value):
|
||||
entry = controlled_entry()
|
||||
claim = _served(entry)
|
||||
if value != "missing":
|
||||
claim["binding"]["human_control"] = value
|
||||
claim["entries"] = [{"subject_id": "human:fixture", "principal_type": "human"}]
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
authorize_action(_Cfg(_token(tmp_path)), entry, "deactivate", fields=("api_token",),
|
||||
opener=_opener(claim), pdp_opener=lambda *a, **k: pytest.fail("must not call PDP"))
|
||||
|
||||
|
||||
def test_human_control_intent_changes_the_submitted_request_binding():
|
||||
ordinary = _entry()
|
||||
controlled = controlled_entry()
|
||||
def request(entry):
|
||||
return build_action_request(entry, "deactivate", subject_id="service:fixture",
|
||||
subject_type="Service", purpose="same-action", fields=("api_token",))
|
||||
before, after = request(ordinary), request(controlled)
|
||||
assert "human_control" not in before["context"]
|
||||
assert after["context"]["human_control"] is True
|
||||
assert request_digest(before) != request_digest(after)
|
||||
|
||||
|
||||
def test_declared_fact_yields_a_human_control_consume_binding(tmp_path):
|
||||
entry = controlled_entry()
|
||||
claim = _served(entry)
|
||||
claim["binding"]["human_control"] = True
|
||||
authorized = _resolve(_Cfg(_token(tmp_path)), entry, claim)
|
||||
assert authorized.binding.human_control is True
|
||||
assert authorized.as_evidence()["approval_human_control"] is True
|
||||
|
||||
|
||||
def test_legacy_lane_still_accepts_undeclared_claim(tmp_path):
|
||||
entry = _entry()
|
||||
result = _resolve(_Cfg(_token(tmp_path)), entry, _served(entry))
|
||||
assert result is not None
|
||||
assert result.binding.human_control is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["test", "prod"])
|
||||
def test_missing_serving_path_cannot_fall_back_to_lane_or_demo_approval(tmp_path, monkeypatch, stage):
|
||||
entry = replace(controlled_entry(), stage=stage)
|
||||
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
authorize_action(_Cfg(None, approval_url="", approval_token_file=None), entry, "exec")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["test", "prod"])
|
||||
def test_consume_requires_observed_human_control_even_with_demo_enabled(tmp_path, monkeypatch, stage):
|
||||
entry = replace(controlled_entry(), stage=stage)
|
||||
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
require_production_consume(_config(tmp_path), entry,
|
||||
binding=ConsumeBinding(AUTH_ID, "sha256:"+"a"*64),
|
||||
opener=lambda *a, **k: pytest.fail("must not consume"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["test", "prod"])
|
||||
def test_declared_control_still_consumes_with_demo_enabled(tmp_path, monkeypatch, stage):
|
||||
entry = replace(controlled_entry(), stage=stage)
|
||||
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
|
||||
cfg = replace(_config(tmp_path), approval_url="https://approval.example",
|
||||
approval_token_file=_token(tmp_path))
|
||||
binding = ConsumeBinding(AUTH_ID, "sha256:"+"a"*64, human_control=True)
|
||||
calls = []
|
||||
def consume(request, **kwargs):
|
||||
calls.append(request.full_url)
|
||||
response = _opener({"status": "consumed", "request_digest": binding.request_digest})(request)
|
||||
response.getcode = lambda: 200
|
||||
return response
|
||||
result = require_production_consume(cfg, entry, binding=binding, opener=consume)
|
||||
assert result.approval_id == AUTH_ID
|
||||
assert calls == [f"https://approval.example/v1/approvals/{AUTH_ID}/consume"]
|
||||
|
||||
|
||||
def test_human_declaration_is_rechecked_after_pdp(tmp_path, monkeypatch):
|
||||
from secrets_engine import approval_consume
|
||||
entry = controlled_entry()
|
||||
claim = _served(entry)
|
||||
claim["binding"]["human_control"] = True
|
||||
monkeypatch.setattr(approval_consume, "fetch_approval_claim", lambda **k: claim)
|
||||
cfg = _Cfg(_token(tmp_path))
|
||||
pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package,
|
||||
version=cfg.authorization_policy_version)
|
||||
original_validate = approval_consume.validate_decision_envelope
|
||||
def invalidate_after_check(*args, **kwargs):
|
||||
result = original_validate(*args, **kwargs)
|
||||
claim["binding"]["human_control"] = False
|
||||
return result
|
||||
monkeypatch.setattr(approval_consume, "validate_decision_envelope", invalidate_after_check)
|
||||
def check(request, **kwargs):
|
||||
return _opener(pdp.decision(json.loads(request.data)))(request)
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
authorize_action(cfg, entry, "deactivate", fields=("api_token",),
|
||||
policy_targets=(entry.policy_name,), auth_targets=(entry.role_name,), pdp_opener=check)
|
||||
|
||||
|
||||
def test_real_exec_handler_rejects_undeclared_claim_before_consume_backend_child(bound, tmp_path, monkeypatch):
|
||||
raw, _, _ = bound
|
||||
raw["stage"] = "prod"
|
||||
raw["approval"].update(model="decision", authorization_id=AUTH_ID,
|
||||
purpose="contract-test", human_control=True)
|
||||
entry = validate_entry(raw)
|
||||
cfg = replace(_config(tmp_path), approval_url="https://approval.example",
|
||||
approval_token_file=_token(tmp_path), authorization_subject_id="user:alice",
|
||||
authorization_subject_type="Human")
|
||||
claim = _served(entry, action="exec")
|
||||
monkeypatch.setattr(cli, "get_entry", lambda *a: entry)
|
||||
monkeypatch.setattr(cli, "authorize_action", lambda *a, **k: authorize_action(*a, **k,
|
||||
opener=_opener(claim), pdp_opener=lambda *a, **k: pytest.fail("must not check")))
|
||||
for name in ("require_production_consume", "_open_backend"):
|
||||
monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("must not consume or open backend"))
|
||||
with pytest.raises(DecisionError, match="human_control"):
|
||||
cli.cmd_exec(cfg, SimpleNamespace(catalog=entry.id, field="api_token", mode="exec-env",
|
||||
command=raw["delivery_config"]["exec_owner"]["command"]))
|
||||
records = _records(tmp_path)
|
||||
assert [r["result"] for r in records] == ["attempt", "failed-DecisionError"]
|
||||
|
||||
|
||||
def test_factory_catalog_declares_human_control_and_keeps_owner_pending():
|
||||
entry = load_entry(Path(__file__).resolve().parents[1]/"catalog/glas-claude-agent-dev-anthropic.yaml")
|
||||
assert entry.approval["human_control"] is True
|
||||
assert entry.delivery_config["exec_owner"]["status"] == "pending"
|
||||
|
|
@ -355,15 +355,40 @@ def exercise(args):
|
|||
"files": {str(p): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "private": True}
|
||||
for p in [executable, script]},
|
||||
}}
|
||||
if args.human_control:
|
||||
raw["approval"].update(model="decision", human_control=True,
|
||||
decision_ref="synthetic-human-lane-review")
|
||||
owner_entry = validate_entry(raw)
|
||||
req = _expected_request(cfg, owner_entry, "exec", fields=("api_token",))
|
||||
owner_request_digest = pdp.bind_request(req)
|
||||
operator_request("/v1/approvals", {
|
||||
"id": "synthetic-owner-delivery", "binding": {"action": "secrets.exec", "target": {"id": owner_entry.id, "stage": "prod"},
|
||||
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
|
||||
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
|
||||
"pdp_digest": owner_request_digest, "pdp_path": True}, "approval:create")
|
||||
operator_request("/v1/approvals/synthetic-owner-delivery/entries", {}, "approval:approve")
|
||||
def issue_owner(ident, human_control):
|
||||
# The requester declares intent on an unapproved object;
|
||||
# only the subsequent human bind discharges the control.
|
||||
return operator_request("/v1/approvals", {
|
||||
"id": ident, "binding": {"action": "secrets.exec", "target": {"id": owner_entry.id, "stage": "prod"},
|
||||
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
|
||||
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
|
||||
"pdp_digest": owner_request_digest, "pdp_path": True,
|
||||
"human_control": human_control}, "approval:create")
|
||||
issue_owner("synthetic-owner-delivery", args.human_control)
|
||||
if args.human_control:
|
||||
try:
|
||||
operator_request("/v1/approvals/synthetic-owner-delivery/entries", {}, "approval:approve")
|
||||
except HTTPError as error:
|
||||
assert error.code == 403
|
||||
assert engine.get("synthetic-owner-delivery").entries == []
|
||||
else:
|
||||
raise AssertionError("service bound a declared human control")
|
||||
# Explicit local fixture: the positive human entry is seeded
|
||||
# through the store, not a native human login/JWT claim.
|
||||
engine.add_entry("synthetic-owner-delivery", "fixture:human",
|
||||
principal_type="human", evidence_ref="synthetic-store-fixture")
|
||||
issue_owner("synthetic-owner-undeclared", False)
|
||||
operator_request("/v1/approvals/synthetic-owner-undeclared/entries", {}, "approval:approve")
|
||||
receipt["checks"]["declared_control_refuses_real_keycape_service_bind"] = True
|
||||
receipt["limitations"].append("positive human entry seeded in disposable store; no human JWT/PKCE proof")
|
||||
else:
|
||||
operator_request("/v1/approvals/synthetic-owner-delivery/entries", {}, "approval:approve")
|
||||
backend_calls = []
|
||||
class Backend:
|
||||
@contextmanager
|
||||
|
|
@ -380,7 +405,23 @@ def exercise(args):
|
|||
yield Backend()
|
||||
command = raw["delivery_config"]["exec_owner"]["command"]
|
||||
cli_args = SimpleNamespace(catalog=owner_entry.id, field="api_token", mode="exec-env", command=command)
|
||||
with patch.object(cli, "get_entry", return_value=owner_entry), patch.object(cli, "_open_backend", open_fixture_backend):
|
||||
from secrets_engine.decisions import Decision
|
||||
lane_review = Decision("synthetic-human-lane-review", "Synthetic lane review", "approved", None, "local-fixture")
|
||||
with patch.object(cli, "get_entry", return_value=owner_entry), patch.object(cli, "_open_backend", open_fixture_backend), patch.object(cli, "resolve_decision", return_value=lane_review):
|
||||
if args.human_control:
|
||||
owner_entry.approval["authorization_id"] = "synthetic-owner-undeclared"
|
||||
before_checks = len(pdp.calls)
|
||||
try:
|
||||
cli.cmd_exec(cfg, cli_args)
|
||||
except DecisionError as error:
|
||||
assert "human_control" in str(error)
|
||||
assert not backend_calls and len(pdp.calls) == before_checks
|
||||
assert not engine.claim("synthetic-owner-undeclared")["consumed"]
|
||||
else:
|
||||
raise AssertionError("undeclared control reached the protected action")
|
||||
finally:
|
||||
owner_entry.approval["authorization_id"] = "synthetic-owner-delivery"
|
||||
receipt["checks"]["undeclared_control_refused_before_real_pdp_consume_backend"] = True
|
||||
substituted = SimpleNamespace(**vars(cli_args))
|
||||
substituted.command = ["/bin/echo", "substitute"]
|
||||
try:
|
||||
|
|
@ -411,6 +452,11 @@ def exercise(args):
|
|||
"exec_owner_actual_child_excludes_parent_credentials": True,
|
||||
"exec_owner_digest_preserved_by_real_evaluator": True,
|
||||
})
|
||||
if args.human_control:
|
||||
assert pdp.last_decision["binding"]["context"]["human_control"] is True
|
||||
assert engine.claim("synthetic-owner-delivery")["binding"]["human_control"] is True
|
||||
receipt["checks"]["declared_human_intent_preserved_by_real_evaluator"] = True
|
||||
receipt["checks"]["declared_human_fixture_consumed_before_owner_delivery"] = True
|
||||
receipt["exec_owner_scope"] = "Synthetic recipient/backend with real KeyCape, Approval Engine, Flex Auth and Secrets Engine CLI; not native custody or human approval proof"
|
||||
finally:
|
||||
if api_server:
|
||||
|
|
@ -436,7 +482,10 @@ def main():
|
|||
parser.add_argument("--flex-auth-source", required=True, type=Path)
|
||||
parser.add_argument("--receipt", required=True, type=Path)
|
||||
parser.add_argument("--exec-owner", action="store_true", help="also prove catalog-bound child delivery against the actual approval/PDP chain")
|
||||
parser.add_argument("--human-control", action="store_true", help="require declared human control in the exec-owner exercise; positive human entry is an explicit local store fixture")
|
||||
args = parser.parse_args()
|
||||
if args.human_control and not args.exec_owner:
|
||||
parser.error("--human-control requires --exec-owner")
|
||||
if args.receipt.exists():
|
||||
raise SystemExit("receipt path must be new")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ synthetic exec-env transport proof passed; no real secret was read.
|
|||
id: SECRETS-WP-0009-T03
|
||||
status: wait
|
||||
priority: high
|
||||
blocking_reason: "Recipient binding now implemented and proved with the real local approval/PDP chain. Complete exact private owner configuration and native MessagesOwner holder admission, CCR-2026-0019 operator reader, human/audit/service path and scoped live delivery. CCR-2026-0020 was cancelled by its owner."
|
||||
blocking_reason: "Recipient binding and declared human-control request/PEP enforcement implemented and proved with the real local approval/PDP chain. Complete exact private owner configuration and native MessagesOwner holder admission, CCR-2026-0019 operator reader, human/audit/service path and scoped live delivery. CCR-2026-0020 was cancelled by its owner."
|
||||
state_hub_task_id: "f8069c8a-ad6b-5d0b-9a36-c2326699437d"
|
||||
```
|
||||
|
||||
|
|
@ -238,3 +238,30 @@ from operator-owned creation, parent custody and cleanup. No automatic cleanup
|
|||
or live reader proof is claimed. The exact group remains NetKingdom/KeyCape's
|
||||
return, followed by reviewed attended admission and positive/negative evidence.
|
||||
T03 stays wait; no identity, role, secret, runtime or route readiness changed.
|
||||
|
||||
### 2026-09-10 declared human-control consumer return
|
||||
|
||||
Consumed APPROVAL-WP-0002's GH-DEC-2026-016 implementation and schema-v5 release.
|
||||
The existing human action-review requirement is explicit on the factory catalog:
|
||||
`approval.human_control: true`. The CheckRequest carries that intent in its bound
|
||||
context; the consumer requires exact true in the served claim before PDP and
|
||||
again afterward. The internal consume binding carries the observed declaration.
|
||||
Missing service coordinates, ordinary lane approval, stage and unsafe-demo paths
|
||||
cannot substitute for the declared control. Undeclared lanes retain their behavior.
|
||||
The catalog's exec owner stays pending; no policy, role or credential was changed.
|
||||
|
||||
Validation: 388 tests pass, including 27 new strict-type, missing/false claim,
|
||||
request binding, post-PDP recheck, fallback and actual exec-handler cases.
|
||||
The real local KeyCape/Approval Engine/Flex Auth exercise passes 22 checks.
|
||||
The request API declares true; KeyCape service binding is refused; an undeclared
|
||||
claim reaches no PDP/consume/backend; declared positive delivery consumes before
|
||||
the synthetic backend/child. Positive human evidence is an explicit store fixture,
|
||||
not a native human JWT or browser login. Evidence and repeatable command:
|
||||
`docs/evidence/2026-09-10-human-control-consumer-exercise.json`,
|
||||
`tools/exercise_approval_identity.py --exec-owner --human-control`.
|
||||
|
||||
This closes consumer source adoption. T03 still requires the narrow native
|
||||
requester, INFD T07/T08 functional browser and human approval, audit custody,
|
||||
CCR-2026-0019 operator binding/attended read, current deployed service/PDP and
|
||||
configured MessagesOwner/native delivery. HFACT T01/T04/T05 retains factory
|
||||
config/G0 and natural queue/model proof. No native secret read or paid attempt.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue