Coverage for src/ai_jury/configtrust.py: 99%
76 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06:29 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06:29 +0000
1"""Trust gate for an auto-discovered ``jury.toml`` that runs local commands (#831).
3The threat is a person running ``jury`` inside a repository they did not write — a clone
4from a link, a fork's pull-request branch checked out to review. ``jury`` auto-discovers
5``./jury.toml``, and a ``[[agent]]`` there can carry a ``command`` (the generic-CLI adapter):
6``command = "sh"`` with ``extra_args = ["-c", "…"]`` is arbitrary code execution the moment
7the review runs. The rest of the config surface is already default-secure — a relative-path
8command is refused (``config._is_relative_path_command``) and a non-loopback endpoint needs an
9env opt-in — but a bare command name resolved from ``PATH`` is not, and that is the gap here.
11So a ``command``-bearing config that keel *discovered* (rather than one the operator named with
12``--config``) must be trusted before its commands run. Trust is one of, in order: the
13``JURY_TRUST_PROJECT_CONFIG`` env opt-in (the same outside-the-config pattern the endpoint
14opt-in uses); a recorded entry keyed by the file's real path **and** a hash of its bytes (so an
15edit re-asks); or, at a terminal, an explicit confirmation that records that entry. Off a
16terminal with none of those, the run is refused with an actionable message. ``jury init``
17records trust for the config it writes, so the ordinary ``init`` → ``jury`` path never prompts.
18"""
20from __future__ import annotations
22import hashlib
23import os
24import sys
25from pathlib import Path
27TRUST_ENV = "JURY_TRUST_PROJECT_CONFIG"
28_DISCOVERED_NAME = "jury.toml"
31class ConfigTrustError(Exception):
32 """Raised when an auto-discovered command-bearing config is not trusted."""
35def _truthy_env(value: str | None) -> bool:
36 return (value or "").strip().lower() in {"1", "true", "yes", "on"}
39def _config_dir() -> Path:
40 base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
41 return Path(base) / "ai-jury"
44def trust_store_path() -> Path:
45 return _config_dir() / "trusted-configs"
48def content_digest(raw: bytes) -> str:
49 return hashlib.sha256(raw).hexdigest()
52def _entry(path: Path, digest: str) -> str:
53 return f"{digest} {os.path.realpath(path)}"
56def is_trusted(path: Path, digest: str) -> bool:
57 try:
58 lines = trust_store_path().read_text(encoding="utf-8").splitlines()
59 except (OSError, ValueError):
60 return False
61 return _entry(path, digest) in lines
64def record_trust(path: Path, digest: str) -> None:
65 entry = _entry(path, digest)
66 store = trust_store_path()
67 try:
68 store.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
69 existing = store.read_text(encoding="utf-8").splitlines() if store.exists() else []
70 if entry not in existing: 70 ↛ exitline 70 didn't return from function 'record_trust' because the condition on line 70 was always true
71 with store.open("a", encoding="utf-8") as handle:
72 handle.write(entry + "\n")
73 except (OSError, ValueError):
74 # ValueError covers a store whose bytes are not UTF-8 — fail soft like is_trusted.
75 # Trust that cannot be persisted is not fatal: the run still proceeds this
76 # time (the caller only records after a positive trust decision), it will
77 # just ask again next time.
78 return
81def command_seats(config) -> list[str]:
82 """Names of agents whose seat runs a local ``command`` (empty when none do)."""
83 seats = []
84 for spec in getattr(config, "agents", ()):
85 if str(getattr(spec, "command", "") or "").strip():
86 seats.append(getattr(spec, "name", "?"))
87 return seats
90def _discovered_path(config_arg: str | None) -> Path | None:
91 """The file keel auto-discovered, or None when the config was not discovered.
93 ``--config`` (a named path) and the built-in default (no file on disk) are both
94 deliberate, so neither is gated; only ``./jury.toml`` picked up from the working
95 directory is.
96 """
97 if config_arg is not None:
98 return None
99 candidate = Path(_DISCOVERED_NAME)
100 return candidate if candidate.is_file() else None
103def enforce(config_arg, config, *, mock: bool, stdin=None, stdout=None) -> None:
104 """Refuse to run a discovered, command-bearing ``jury.toml`` unless it is trusted.
106 Raises :class:`ConfigTrustError` when the run must not proceed. A no-op for an
107 explicit ``--config``, the built-in default, a mock run, or a config with no
108 ``command`` seat.
109 """
110 if mock:
111 return
112 path = _discovered_path(config_arg)
113 if path is None:
114 return
115 seats = command_seats(config)
116 if not seats:
117 return
118 if _truthy_env(os.environ.get(TRUST_ENV)):
119 return
120 try:
121 raw = path.read_bytes()
122 except OSError as exc:
123 raise ConfigTrustError(f"cannot read {path} to check whether it is trusted: {exc}") from exc
124 digest = content_digest(raw)
125 if is_trusted(path, digest):
126 return
128 listed = ", ".join(seats)
129 stream_in = stdin if stdin is not None else sys.stdin
130 stream_out = stdout if stdout is not None else sys.stdout
131 if stream_in is not None and hasattr(stream_in, "isatty") and stream_in.isatty():
132 # Read the answer from the passed-in streams, not the builtin ``input()`` — that
133 # would always read the real ``sys.stdin`` and ignore an injected stream.
134 print(
135 f"\n{path} was found in this directory and runs local command(s) as part of the "
136 f"review:\n agents with a command: {listed}\n"
137 "A cloned or fork repository can ship a jury.toml that runs arbitrary commands.\n"
138 "Trust this config and run its commands? [y/N] ",
139 end="",
140 file=stream_out,
141 flush=True,
142 )
143 answer = (stream_in.readline() or "").strip().lower()
144 if answer in {"y", "yes"}:
145 record_trust(path, digest)
146 return
147 raise ConfigTrustError("not trusted by the operator; nothing was run")
149 raise ConfigTrustError(
150 f"refusing to run commands from an auto-discovered {path} without confirmation "
151 f"(agents with a command: {listed}). A cloned or fork repository can ship a jury.toml "
152 f"that runs arbitrary commands. To proceed: pass --config {path} if you trust it, set "
153 f"{TRUST_ENV}=1, or run once in a terminal to confirm."
154 )