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

1"""Trust gate for an auto-discovered ``jury.toml`` that runs local commands (#831). 

2 

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. 

10 

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""" 

19 

20from __future__ import annotations 

21 

22import hashlib 

23import os 

24import sys 

25from pathlib import Path 

26 

27TRUST_ENV = "JURY_TRUST_PROJECT_CONFIG" 

28_DISCOVERED_NAME = "jury.toml" 

29 

30 

31class ConfigTrustError(Exception): 

32 """Raised when an auto-discovered command-bearing config is not trusted.""" 

33 

34 

35def _truthy_env(value: str | None) -> bool: 

36 return (value or "").strip().lower() in {"1", "true", "yes", "on"} 

37 

38 

39def _config_dir() -> Path: 

40 base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") 

41 return Path(base) / "ai-jury" 

42 

43 

44def trust_store_path() -> Path: 

45 return _config_dir() / "trusted-configs" 

46 

47 

48def content_digest(raw: bytes) -> str: 

49 return hashlib.sha256(raw).hexdigest() 

50 

51 

52def _entry(path: Path, digest: str) -> str: 

53 return f"{digest} {os.path.realpath(path)}" 

54 

55 

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 

62 

63 

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 

79 

80 

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 

88 

89 

90def _discovered_path(config_arg: str | None) -> Path | None: 

91 """The file keel auto-discovered, or None when the config was not discovered. 

92 

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 

101 

102 

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. 

105 

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 

127 

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") 

148 

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 )