secrets-engine/tests/test_exec_owner.py

168 lines
8.7 KiB
Python
Raw Normal View History

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 "<command" not in result.next_command