import copy import hashlib import os from pathlib import Path import shutil from types import SimpleNamespace import pytest from secrets_engine import cli, exec_delivery from secrets_engine.authorization import build_action_request, request_digest, validate_decision_envelope from secrets_engine.catalog import load_entry, validate_entry from secrets_engine.errors import CatalogError, DecisionError, DeliveryError from secrets_engine.exec_owner import owner_digest from tests.test_action_authorization import _envelope from tests.test_catalog import VALID from tests.test_lane_state import _cfg @pytest.fixture def bound(tmp_path): private = tmp_path / "owner" private.mkdir(mode=0o700) executable = private / "dash" shutil.copyfile("/bin/dash", executable) executable.chmod(0o700) script = private / "owner.sh" script.write_text('''test "$API_TOKEN" = synthetic-owner-value || exit 10 test -z "$PARENT_CREDENTIAL" || exit 11 test -z "$BAO_TOKEN" || exit 12 test -z "$SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE" || exit 13 test -z "$PYTHONPATH" || exit 14 read ignored && exit 15 printf '%s\\n' "$API_TOKEN" printf 'owner-child-ok\\n' pwd ''') script.chmod(0o600) data = copy.deepcopy(VALID) data["delivery_config"] = {"exec_owner": { "status": "configured", "owner": "fixture-owner", "command": [str(executable), str(script)], "cwd": str(private), "environment": {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}, "files": {str(f): {"sha256": hashlib.sha256(f.read_bytes()).hexdigest(), "private": True} for f in [executable, script]}, }} return data, private, script def test_actual_bound_child_gets_only_selected_secret_and_fixed_environment(bound, monkeypatch, capsys): data, private, _ = bound entry = validate_entry(data) for name in ["PARENT_CREDENTIAL", "BAO_TOKEN", "SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "PYTHONPATH"]: monkeypatch.setenv(name, "parent-sensitive-fixture") monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: "synthetic-owner-value") assert exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], mode="exec-env", expected_owner_digest=owner_digest(entry)) == 0 output = capsys.readouterr().out assert "owner-child-ok" in output and str(private) in output assert "synthetic-owner-value" not in output and "parent-sensitive-fixture" not in output assert os.environ["BAO_TOKEN"] == "parent-sensitive-fixture" @pytest.mark.parametrize("change", ["command", "extra_arg", "config", "symlink", "private_mode", "parent_mode", "mode", "field"]) def test_bound_refusals_happen_before_fetch(bound, monkeypatch, change): data, private, script = bound entry = validate_entry(data) command = list(data["delivery_config"]["exec_owner"]["command"]) mode, field = "exec-env", "api_token" if change == "command": command[0] = "/bin/sh" elif change == "extra_arg": command.append("--other") elif change == "config": script.write_text("changed") elif change == "symlink": saved = script.with_suffix(".saved") script.rename(saved); script.symlink_to(saved) elif change == "private_mode": script.chmod(0o640) elif change == "parent_mode": private.chmod(0o770) elif change == "mode": mode = "exec-file" elif change == "field": field = "wrong" monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must refuse before read")) with pytest.raises(DeliveryError): exec_delivery.exec_with_secret(object(), entry, field, command, mode=mode) def test_config_changed_during_fetch_does_not_launch(bound, monkeypatch): data, _, script = bound entry = validate_entry(data) def fetch(*a, **k): script.write_text("changed during fetch") return "synthetic-owner-value" monkeypatch.setattr(exec_delivery, "_fetch_value", fetch) monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch")) with pytest.raises(DeliveryError, match="digest"): exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"]) def test_binding_changed_after_approval_refuses_before_fetch(bound, monkeypatch): data, _, _ = bound entry = validate_entry(data) expected = owner_digest(entry) entry.delivery_config["exec_owner"]["environment"]["LANG"] = "C" monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must not read")) with pytest.raises(DeliveryError, match="after action admission"): exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], expected_owner_digest=expected) def test_pending_real_catalog_refuses_before_approval_and_backend(tmp_path, monkeypatch): entry = load_entry(Path(__file__).resolve().parents[1] / "catalog/glas-claude-agent-dev-anthropic.yaml") monkeypatch.setattr(cli, "get_entry", lambda *a: entry) for name in ["_require_lane_approval", "_open_backend"]: monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("no approval consume or backend")) with pytest.raises(DeliveryError, match="pending"): cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=["/bin/echo"], mode="exec-env")) def test_configured_command_refusal_precedes_approval_and_backend(bound, monkeypatch, tmp_path): data, _, _ = bound entry = validate_entry(data) monkeypatch.setattr(cli, "get_entry", lambda *a: entry) for name in ["_require_lane_approval", "_open_backend"]: monkeypatch.setattr(cli, name, lambda *a, **k: pytest.fail("no approval consume or backend")) with pytest.raises(DeliveryError, match="catalog-bound"): cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=["/bin/echo"], mode="exec-env")) @pytest.mark.parametrize("changed", ["command", "cwd", "environment", "files", "owner"]) def test_changed_recipient_cannot_replay_prior_decision(bound, changed): data, private, script = bound entry = validate_entry(data) def request(e): return build_action_request(e, "exec", subject_id="agent:fixture", subject_type="Agent", purpose="owner-proof", fields=["api_token"], request_id="fixture-owner-proof") before = request(entry) envelope = _envelope(before) other = copy.deepcopy(data) binding = other["delivery_config"]["exec_owner"] if changed == "command": binding["command"].append("--other") elif changed == "cwd": binding["cwd"] = str(private / "other") elif changed == "environment": binding["environment"]["LANG"] = "C" elif changed == "files": binding["files"][str(script)]["sha256"] = "1" * 64 else: binding["owner"] = "another-owner" after = request(validate_entry(other)) assert request_digest(before) != request_digest(after) with pytest.raises(DecisionError): validate_decision_envelope(envelope, after, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}) assert "exec_owner_sha256" not in request(validate_entry(VALID))["context"] @pytest.mark.parametrize("change", ["null", "unknown", "relative", "missing_pin", "null_private", "preload", "credential", "other_mode"]) def test_invalid_binding_is_not_a_catalog_fallback(bound, change): data, _, _ = bound binding = data["delivery_config"]["exec_owner"] if change == "null": data["delivery_config"]["exec_owner"] = None elif change == "unknown": binding["extra"] = True elif change == "relative": binding["command"][0] = "python3" elif change == "missing_pin": binding["files"] = {} elif change == "null_private": next(iter(binding["files"].values()))["private"] = None elif change == "preload": binding["environment"]["LD_PRELOAD"] = "/tmp/loader" elif change == "credential": binding["environment"]["SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE"] = "/tmp/credential" else: data["delivery_modes"].append("exec-file") with pytest.raises(CatalogError): validate_entry(data) def test_pending_owner_never_advertises_ready(tmp_path, monkeypatch): from secrets_engine import routing entry = load_entry(Path(__file__).resolve().parents[1] / "catalog/glas-claude-agent-dev-anthropic.yaml") monkeypatch.setattr(routing, "resolve_decision", lambda **k: SimpleNamespace(status="approved", review_url="", is_approved=lambda: True)) client = SimpleNamespace(is_reachable=lambda: True, read_policy=lambda p: "policy", approle_exists=lambda r: True, kv_fields_present=lambda *a: {"ANTHROPIC_API_KEY": True}) result = routing.route_lane(entry, hub_url="", repo_root=tmp_path, client=client) assert not result.ready and "exec owner" in result.missing assert "