conduct-cli 0.7.14__tar.gz → 0.7.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/guard.py +3 -1
  4. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/guardmcp.py +21 -7
  5. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/base.py +19 -5
  6. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/pretooluse.py +8 -1
  7. conduct_cli-0.7.17/src/conduct_cli/tool_groups.py +58 -0
  8. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  9. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  10. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_advisory_and_hook.py +62 -0
  11. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_guard_policy.py +8 -4
  12. conduct_cli-0.7.17/tests/test_proxy_coverage.py +79 -0
  13. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/README.md +0 -0
  14. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/setup.cfg +0 -0
  15. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/setup.py +0 -0
  16. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/__init__.py +0 -0
  17. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/api.py +0 -0
  18. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/__init__.py +0 -0
  19. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/posttooluse.py +0 -0
  20. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/precompact.py +0 -0
  21. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_parser.py +0 -0
  22. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_report_push.py +0 -0
  23. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/session_start.py +0 -0
  24. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/hooks/stop.py +0 -0
  25. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/log_util.py +0 -0
  26. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/main.py +0 -0
  27. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/mcp_server.py +0 -0
  28. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/memory.py +0 -0
  29. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli/paxel.py +0 -0
  30. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_bash_operator_signature.py +0 -0
  35. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_blast_radius.py +0 -0
  36. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_command_word_matcher.py +0 -0
  37. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_drain_daemon_health.py +0 -0
  38. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_guard_savings.py +0 -0
  39. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_hook_dispatch.py +0 -0
  40. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_journal_drain.py +0 -0
  41. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_log_util.py +0 -0
  42. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_proxy_env.py +0 -0
  43. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_signed_policy.py +0 -0
  44. {conduct_cli-0.7.14 → conduct_cli-0.7.17}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.14
3
+ Version: 0.7.17
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.7.14"
7
+ version = "0.7.17"
8
8
  description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -9,6 +9,8 @@ import urllib.request
9
9
  from datetime import datetime, timedelta, timezone
10
10
  from pathlib import Path
11
11
 
12
+ from conduct_cli.tool_groups import expand_match_tool
13
+
12
14
  RESET = "\033[0m"
13
15
  BOLD = "\033[1m"
14
16
  GREEN = "\033[32m"
@@ -132,7 +134,7 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
132
134
  for rule in rules:
133
135
  match_tool = (rule.get("match_tool") or "*").lower()
134
136
  if match_tool != "*":
135
- if tool_name not in [t.strip() for t in match_tool.split(",")]:
137
+ if tool_name not in expand_match_tool(match_tool):
136
138
  continue
137
139
  # New: structural match against the actual binary being invoked.
138
140
  # Single string or list of strings; case-insensitive equality.
@@ -12,6 +12,7 @@ Exposes three tools:
12
12
  guard_sync — pull latest policy from the ConductGuard API
13
13
  """
14
14
  from __future__ import annotations
15
+ from conduct_cli.tool_groups import expand_match_tool
15
16
  import argparse
16
17
  import json
17
18
  import re
@@ -183,14 +184,23 @@ def _maybe_sync() -> None:
183
184
  def _detect_surface(client_info: dict) -> str:
184
185
  """Map MCP clientInfo.name → ai_tool label sent to Guard API."""
185
186
  name = (client_info.get("name") or "").lower()
186
- if "desktop" in name:
187
- return "claude_desktop"
187
+ # ── Anthropic ─────────────────────────────────────────────────────────────
188
+ if "code" in name and "claude" in name:
189
+ return "claude-code"
190
+ if "desktop" in name and "claude" in name:
191
+ return "claude-desktop"
188
192
  if "work" in name or "teams" in name or "enterprise" in name:
189
- return "claude_work"
193
+ return "claude-desktop" # Claude for Work / Teams
190
194
  if "claude" in name:
191
- return "claude_chat"
195
+ return "claude-ai" # claude.ai web
196
+ # ── OpenAI ────────────────────────────────────────────────────────────────
197
+ if "chatgpt" in name or "openai" in name:
198
+ return "openai-chatgpt"
199
+ if "codex" in name and "desktop" in name:
200
+ return "codex-desktop"
192
201
  if "codex" in name:
193
- return "codex"
202
+ return "codex-cli"
203
+ # ── Other IDEs ────────────────────────────────────────────────────────────
194
204
  if "cursor" in name:
195
205
  return "cursor"
196
206
  if "windsurf" in name:
@@ -259,9 +269,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
259
269
  for rule in rules:
260
270
  match_tool = (rule.get("match_tool") or "*").lower()
261
271
  if match_tool != "*":
262
- allowed = [t.strip() for t in match_tool.split(",")]
263
- if tool_name.lower() not in allowed:
272
+ if tool_name.lower() not in expand_match_tool(match_tool):
264
273
  continue
274
+ match_ai = rule.get("match_ai_tool")
275
+ if match_ai:
276
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
277
+ if not any(s in ai_tool.lower() for s in surfaces):
278
+ continue # rule targets a different surface
265
279
 
266
280
  pattern = rule.get("match_pattern")
267
281
  if pattern:
@@ -74,24 +74,38 @@ def detect_repo() -> Optional[str]:
74
74
 
75
75
 
76
76
  def detect_ai_tool() -> str:
77
- """Return a string identifying the active AI coding tool."""
77
+ """Return a string identifying the active AI coding tool / surface."""
78
78
  import os
79
+ # ── Anthropic ─────────────────────────────────────────────────────────────
79
80
  if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
80
81
  return "claude-code"
81
- if os.environ.get("CODEX_SESSION_ID") or os.environ.get("CODEX_CLI_VERSION"):
82
- return "codex"
82
+ if os.environ.get("CLAUDE_DESKTOP_ENTRYPOINT") or os.environ.get("CLAUDE_DESKTOP"):
83
+ return "claude-desktop"
84
+ # ── OpenAI / Codex ────────────────────────────────────────────────────────
85
+ if os.environ.get("CODEX_CLI_VERSION") or os.environ.get("CODEX_SESSION_ID"):
86
+ return "codex-cli"
87
+ if os.environ.get("CODEX_DESKTOP") or os.environ.get("OPENAI_CODEX_DESKTOP"):
88
+ return "codex-desktop"
89
+ if os.environ.get("OPENAI_CHATGPT_MCP") or os.environ.get("CHATGPT_SESSION_ID"):
90
+ return "openai-chatgpt"
91
+ # ── TERM_PROGRAM ──────────────────────────────────────────────────────────
83
92
  term = os.environ.get("TERM_PROGRAM", "").lower()
84
93
  if term == "cursor":
85
94
  return "cursor"
86
95
  if term == "windsurf":
87
96
  return "windsurf"
97
+ # ── PATH heuristics ───────────────────────────────────────────────────────
88
98
  path = os.environ.get("PATH", "")
89
- if "Codex.app" in path or "codex" in path.lower():
90
- return "codex"
99
+ if "Codex.app" in path:
100
+ return "codex-desktop"
101
+ if "codex" in path.lower():
102
+ return "codex-cli"
91
103
  if "cursor" in path.lower():
92
104
  return "cursor"
93
105
  if "windsurf" in path.lower():
94
106
  return "windsurf"
107
+ if "claude" in path.lower():
108
+ return "claude-desktop"
95
109
  return "claude-code"
96
110
 
97
111
 
@@ -10,6 +10,7 @@ import time
10
10
  import urllib.request
11
11
  from pathlib import Path
12
12
 
13
+ from conduct_cli.tool_groups import expand_match_tool
13
14
  from conduct_cli.hooks.base import (
14
15
  BUDGET_CACHE_PATH,
15
16
  BUDGET_CACHE_TTL,
@@ -325,10 +326,16 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
325
326
  input_text = json.dumps(tool_input)
326
327
  path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
327
328
 
329
+ current_ai_tool = detect_ai_tool()
328
330
  for rule in rules:
329
331
  match_tool = (rule.get("match_tool") or "*").lower()
330
332
  if match_tool != "*":
331
- if tool_name not in [t.strip() for t in match_tool.split(",")]:
333
+ if tool_name not in expand_match_tool(match_tool):
334
+ continue
335
+ match_ai = rule.get("match_ai_tool")
336
+ if match_ai:
337
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
338
+ if not any(s in current_ai_tool.lower() for s in surfaces):
332
339
  continue
333
340
  pattern = rule.get("match_pattern")
334
341
  if pattern:
@@ -0,0 +1,58 @@
1
+ """
2
+ Canonical tool group mapping — single source of truth for semantic match_tool names.
3
+ Shared across CLI hook, MCP, and API (mirrored at apps/api/app/modules/guard/tool_groups.py).
4
+ """
5
+
6
+ TOOL_GROUPS: dict[str, set[str]] = {
7
+ "shell": {"bash", "run_command", "execute", "terminal", "shell"},
8
+ "filesystem-write": {"write", "edit", "write_file", "edit_file", "str_replace_editor"},
9
+ "filesystem-read": {"read", "read_file", "glob", "grep", "list_directory"},
10
+ "network": {"web_fetch", "web_search", "http_request", "curl", "fetch"},
11
+ }
12
+
13
+ # Inverted: raw tool name → semantic group
14
+ _RAW_TO_GROUP: dict[str, str] = {
15
+ raw: group
16
+ for group, raws in TOOL_GROUPS.items()
17
+ for raw in raws
18
+ }
19
+
20
+
21
+ def normalize_tool(tool_name: str) -> str:
22
+ """Map a raw tool name to its semantic group, or return as-is if unknown."""
23
+ return _RAW_TO_GROUP.get(tool_name.lower(), tool_name.lower())
24
+
25
+
26
+ def expand_match_tool(match_tool: str) -> set[str]:
27
+ """
28
+ Expand a match_tool value (semantic group or raw name, comma-separated)
29
+ into the full set of raw tool names it covers.
30
+ """
31
+ result: set[str] = set()
32
+ for token in match_tool.split(","):
33
+ token = token.strip().lower()
34
+ if token == "*":
35
+ return {"*"}
36
+ if token in TOOL_GROUPS:
37
+ result |= TOOL_GROUPS[token]
38
+ else:
39
+ result.add(token)
40
+ return result
41
+
42
+
43
+ def collapse_to_group(match_tool: str) -> str:
44
+ """
45
+ Collapse a comma-separated match_tool value to semantic group names.
46
+ e.g. "write,edit" → "filesystem-write"
47
+ "bash,run_command" → "shell"
48
+ "bash,write" → "shell,filesystem-write"
49
+ """
50
+ if not match_tool or match_tool.strip() == "*":
51
+ return "*"
52
+ tokens = [t.strip().lower() for t in match_tool.split(",") if t.strip()]
53
+ groups_seen: list[str] = []
54
+ for token in tokens:
55
+ group = _RAW_TO_GROUP.get(token, token)
56
+ if group not in groups_seen:
57
+ groups_seen.append(group)
58
+ return ",".join(groups_seen)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.14
3
+ Version: 0.7.17
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -10,6 +10,7 @@ src/conduct_cli/main.py
10
10
  src/conduct_cli/mcp_server.py
11
11
  src/conduct_cli/memory.py
12
12
  src/conduct_cli/paxel.py
13
+ src/conduct_cli/tool_groups.py
13
14
  src/conduct_cli.egg-info/PKG-INFO
14
15
  src/conduct_cli.egg-info/SOURCES.txt
15
16
  src/conduct_cli.egg-info/dependency_links.txt
@@ -35,6 +36,7 @@ tests/test_guard_savings.py
35
36
  tests/test_hook_dispatch.py
36
37
  tests/test_journal_drain.py
37
38
  tests/test_log_util.py
39
+ tests/test_proxy_coverage.py
38
40
  tests/test_proxy_env.py
39
41
  tests/test_signed_policy.py
40
42
  tests/test_switch.py
@@ -542,3 +542,65 @@ class TestInvalidStdin:
542
542
  with pytest.raises(SystemExit) as exc:
543
543
  pt.main()
544
544
  assert exc.value.code == 0
545
+
546
+
547
+ # ── Fail-closed: API unreachable (budget check path) ──────────────────────────
548
+
549
+ class TestFailClosedApiUnreachable:
550
+ """fail_closed + policy exists + budget API unreachable → blocks with clear message."""
551
+
552
+ def test_api_unreachable_blocks_with_clear_message(self, monkeypatch, tmp_path, capsys):
553
+ import conduct_cli.hooks.pretooluse as pt
554
+ from io import StringIO
555
+
556
+ policy_file = tmp_path / "policy.json"
557
+ policy_file.write_text('{"rules": [], "fail_mode": "fail_closed"}')
558
+
559
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
560
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
561
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
562
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
563
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
564
+ # Budget cache miss, then API unreachable → returns (False, None)
565
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
566
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
567
+
568
+ posted: list[dict] = []
569
+ def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
570
+ posted.append({"decision": decision, "rule_id": rule_id, "msg": msg})
571
+ monkeypatch.setattr(pt, "post_event", _fake_post)
572
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
573
+
574
+ with pytest.raises(SystemExit) as exc:
575
+ pt.main()
576
+
577
+ assert exc.value.code == 2
578
+ assert posted[0]["decision"] == "blocked"
579
+ assert posted[0]["rule_id"] == "guard-unavailable"
580
+ # Message must say "unreachable", not "budget"
581
+ assert "unreachable" in posted[0]["msg"].lower()
582
+ assert "budget" not in posted[0]["msg"].lower()
583
+
584
+ def test_api_unreachable_fail_open_passes(self, monkeypatch, tmp_path):
585
+ """Same scenario but fail_open → should NOT block."""
586
+ import conduct_cli.hooks.pretooluse as pt
587
+ from io import StringIO
588
+
589
+ policy_file = tmp_path / "policy.json"
590
+ policy_file.write_text('{"rules": []}')
591
+
592
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
593
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
594
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
595
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
596
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
597
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
598
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
599
+ monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
600
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
601
+
602
+ # Should not raise SystemExit(2) — exits 0 (allow)
603
+ try:
604
+ pt.main()
605
+ except SystemExit as e:
606
+ assert e.code == 0
@@ -97,22 +97,26 @@ class TestMatchTool:
97
97
  assert action == "warn"
98
98
 
99
99
  def test_exact_tool_match(self, policy_path):
100
- policy_path([_rule(match_tool="bash", action="warn")])
100
+ # semantic group "shell" matches raw tool "bash"
101
+ policy_path([_rule(match_tool="shell", action="warn")])
101
102
  _, action, _, _ = _check_policy("bash", {})
102
103
  assert action == "warn"
103
104
 
104
105
  def test_exact_tool_no_match(self, policy_path):
105
- policy_path([_rule(match_tool="edit", action="block")])
106
+ # "filesystem-write" does not match "bash"
107
+ policy_path([_rule(match_tool="filesystem-write", action="block")])
106
108
  _, action, _, _ = _check_policy("bash", {})
107
109
  assert action == "allow"
108
110
 
109
111
  def test_comma_separated_includes_tool(self, policy_path):
110
- policy_path([_rule(match_tool="bash,edit,write", action="warn")])
112
+ # "shell,filesystem-write" matches "edit" (filesystem-write group)
113
+ policy_path([_rule(match_tool="shell,filesystem-write", action="warn")])
111
114
  _, action, _, _ = _check_policy("edit", {})
112
115
  assert action == "warn"
113
116
 
114
117
  def test_comma_separated_excludes_tool(self, policy_path):
115
- policy_path([_rule(match_tool="bash,edit,write", action="warn")])
118
+ # "shell,filesystem-write" does not match "read" (filesystem-read group)
119
+ policy_path([_rule(match_tool="shell,filesystem-write", action="warn")])
116
120
  _, action, _, _ = _check_policy("read", {})
117
121
  assert action == "allow"
118
122
 
@@ -0,0 +1,79 @@
1
+ """Tests for proxy coverage detection in guard status."""
2
+ import io
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock, patch
6
+
7
+
8
+ def _run_coverage_block(env: dict, cfg: dict, env_file_exists: bool = True) -> str:
9
+ """Run only the proxy-coverage printing block from cmd_guard_status."""
10
+ import conduct_cli.guard as g
11
+
12
+ output = io.StringIO()
13
+ with patch.dict("os.environ", env, clear=True), \
14
+ patch.object(Path, "exists", return_value=env_file_exists):
15
+ proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
16
+ _env = env
17
+ anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
18
+ openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
19
+ perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL", "")
20
+
21
+ covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
22
+ uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
23
+
24
+ GREEN, YELLOW, RED, RESET, BOLD = "", "", "", "", ""
25
+
26
+ if covered and not uncovered:
27
+ line = f"active — {', '.join(covered)} routed through Guard proxy"
28
+ elif covered:
29
+ line = f"partial — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
30
+ elif env_file_exists:
31
+ line = "inactive — ~/.conduct/env exists but not sourced in this shell."
32
+ else:
33
+ line = "not configured — run: conduct guard sync"
34
+ return line
35
+
36
+
37
+ _CFG = {"api_url": "https://api.conductai.ai"}
38
+ _PROXY = "https://api.conductai.ai/proxy"
39
+
40
+
41
+ def test_all_three_active():
42
+ env = {
43
+ "ANTHROPIC_BASE_URL": _PROXY,
44
+ "OPENAI_BASE_URL": f"{_PROXY}/openai",
45
+ "PERPLEXITY_BASE_URL": f"{_PROXY}/perplexity",
46
+ }
47
+ line = _run_coverage_block(env, _CFG)
48
+ assert line.startswith("active")
49
+ assert "Anthropic" in line
50
+ assert "OpenAI" in line
51
+ assert "Perplexity" in line
52
+
53
+
54
+ def test_partial_coverage():
55
+ env = {"ANTHROPIC_BASE_URL": _PROXY}
56
+ line = _run_coverage_block(env, _CFG)
57
+ assert line.startswith("partial")
58
+ assert "Anthropic" in line
59
+ assert "NOT intercepted" in line
60
+ assert "OpenAI" in line
61
+
62
+
63
+ def test_inactive_env_file_exists():
64
+ line = _run_coverage_block({}, _CFG, env_file_exists=True)
65
+ assert line.startswith("inactive")
66
+ assert "~/.conduct/env" in line
67
+
68
+
69
+ def test_not_configured_no_env_file():
70
+ line = _run_coverage_block({}, _CFG, env_file_exists=False)
71
+ assert line.startswith("not configured")
72
+ assert "conduct guard sync" in line
73
+
74
+
75
+ def test_wrong_proxy_url_not_counted():
76
+ """Env var pointing to a different proxy should not count as covered."""
77
+ env = {"ANTHROPIC_BASE_URL": "https://some-other-proxy.io"}
78
+ line = _run_coverage_block(env, _CFG, env_file_exists=True)
79
+ assert line.startswith("inactive")
File without changes
File without changes
File without changes