diff --git a/catalog/glas-claude-agent-dev-anthropic.yaml b/catalog/glas-claude-agent-dev-anthropic.yaml index 133a9e7..785d1ff 100644 --- a/catalog/glas-claude-agent-dev-anthropic.yaml +++ b/catalog/glas-claude-agent-dev-anthropic.yaml @@ -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 diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index 5b71fe4..5ea5597 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -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 | | --- | --- | diff --git a/docs/catalog-admission.md b/docs/catalog-admission.md index 7b31c9f..820f01f 100644 --- a/docs/catalog-admission.md +++ b/docs/catalog-admission.md @@ -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. diff --git a/docs/evidence/2026-09-10-human-control-consumer-exercise.json b/docs/evidence/2026-09-10-human-control-consumer-exercise.json new file mode 100644 index 0000000..b000fb6 --- /dev/null +++ b/docs/evidence/2026-09-10-human-control-consumer-exercise.json @@ -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" +} diff --git a/src/secrets_engine/approval_claim.py b/src/secrets_engine/approval_claim.py index f4efe4f..5ad3b35 100644 --- a/src/secrets_engine/approval_claim.py +++ b/src/secrets_engine/approval_claim.py @@ -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: diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index f9b5096..f86f419 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -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 " diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index 557c292..d9deef7 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -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 diff --git a/src/secrets_engine/catalog.py b/src/secrets_engine/catalog.py index 70c6403..09fd102 100644 --- a/src/secrets_engine/catalog.py +++ b/src/secrets_engine/catalog.py @@ -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 = "") -> 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 = "") -> 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") diff --git a/tests/test_human_control.py b/tests/test_human_control.py new file mode 100644 index 0000000..5dd95e4 --- /dev/null +++ b/tests/test_human_control.py @@ -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" diff --git a/tools/exercise_approval_identity.py b/tools/exercise_approval_identity.py index 70cdd6c..af4f15d 100644 --- a/tools/exercise_approval_identity.py +++ b/tools/exercise_approval_identity.py @@ -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: diff --git a/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md b/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md index 18b854e..cbccb43 100644 --- a/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md +++ b/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md @@ -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.