Bind credential exec to exact owner inputs and approval digest
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
9eb07fd8fc
commit
42b48aa54f
13 changed files with 626 additions and 7 deletions
167
tests/test_exec_owner.py
Normal file
167
tests/test_exec_owner.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue