conduct-cli 0.7.14__tar.gz → 0.7.16__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 (43) hide show
  1. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/guardmcp.py +20 -7
  4. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/base.py +19 -5
  5. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/pretooluse.py +6 -0
  6. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  7. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  8. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_advisory_and_hook.py +62 -0
  9. conduct_cli-0.7.16/tests/test_proxy_coverage.py +79 -0
  10. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/README.md +0 -0
  11. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/setup.cfg +0 -0
  12. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/setup.py +0 -0
  13. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/__init__.py +0 -0
  14. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/api.py +0 -0
  15. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/guard.py +0 -0
  16. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/__init__.py +0 -0
  17. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/posttooluse.py +0 -0
  18. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/precompact.py +0 -0
  19. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_parser.py +0 -0
  20. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_report_push.py +0 -0
  21. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_start.py +0 -0
  22. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/stop.py +0 -0
  23. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/log_util.py +0 -0
  24. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/main.py +0 -0
  25. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/mcp_server.py +0 -0
  26. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/memory.py +0 -0
  27. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/paxel.py +0 -0
  28. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  29. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  30. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/requires.txt +0 -0
  31. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
  32. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_bash_operator_signature.py +0 -0
  33. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_blast_radius.py +0 -0
  34. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_command_word_matcher.py +0 -0
  35. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_drain_daemon_health.py +0 -0
  36. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_guard_policy.py +0 -0
  37. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_guard_savings.py +0 -0
  38. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_hook_dispatch.py +0 -0
  39. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_journal_drain.py +0 -0
  40. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_log_util.py +0 -0
  41. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_proxy_env.py +0 -0
  42. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_signed_policy.py +0 -0
  43. {conduct_cli-0.7.14 → conduct_cli-0.7.16}/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.16
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.16"
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" }
@@ -183,14 +183,23 @@ def _maybe_sync() -> None:
183
183
  def _detect_surface(client_info: dict) -> str:
184
184
  """Map MCP clientInfo.name → ai_tool label sent to Guard API."""
185
185
  name = (client_info.get("name") or "").lower()
186
- if "desktop" in name:
187
- return "claude_desktop"
186
+ # ── Anthropic ─────────────────────────────────────────────────────────────
187
+ if "code" in name and "claude" in name:
188
+ return "claude-code"
189
+ if "desktop" in name and "claude" in name:
190
+ return "claude-desktop"
188
191
  if "work" in name or "teams" in name or "enterprise" in name:
189
- return "claude_work"
192
+ return "claude-desktop" # Claude for Work / Teams
190
193
  if "claude" in name:
191
- return "claude_chat"
194
+ return "claude-ai" # claude.ai web
195
+ # ── OpenAI ────────────────────────────────────────────────────────────────
196
+ if "chatgpt" in name or "openai" in name:
197
+ return "openai-chatgpt"
198
+ if "codex" in name and "desktop" in name:
199
+ return "codex-desktop"
192
200
  if "codex" in name:
193
- return "codex"
201
+ return "codex-cli"
202
+ # ── Other IDEs ────────────────────────────────────────────────────────────
194
203
  if "cursor" in name:
195
204
  return "cursor"
196
205
  if "windsurf" in name:
@@ -259,9 +268,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
259
268
  for rule in rules:
260
269
  match_tool = (rule.get("match_tool") or "*").lower()
261
270
  if match_tool != "*":
262
- allowed = [t.strip() for t in match_tool.split(",")]
263
- if tool_name.lower() not in allowed:
271
+ if tool_name.lower() not in [t.strip() for t in match_tool.split(",")]:
264
272
  continue
273
+ match_ai = rule.get("match_ai_tool")
274
+ if match_ai:
275
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
276
+ if not any(s in ai_tool.lower() for s in surfaces):
277
+ continue # rule targets a different surface
265
278
 
266
279
  pattern = rule.get("match_pattern")
267
280
  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
 
@@ -325,11 +325,17 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
325
325
  input_text = json.dumps(tool_input)
326
326
  path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
327
327
 
328
+ current_ai_tool = detect_ai_tool()
328
329
  for rule in rules:
329
330
  match_tool = (rule.get("match_tool") or "*").lower()
330
331
  if match_tool != "*":
331
332
  if tool_name not in [t.strip() for t in match_tool.split(",")]:
332
333
  continue
334
+ match_ai = rule.get("match_ai_tool")
335
+ if match_ai:
336
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
337
+ if not any(s in current_ai_tool.lower() for s in surfaces):
338
+ continue
333
339
  pattern = rule.get("match_pattern")
334
340
  if pattern:
335
341
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.14
3
+ Version: 0.7.16
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
@@ -35,6 +35,7 @@ tests/test_guard_savings.py
35
35
  tests/test_hook_dispatch.py
36
36
  tests/test_journal_drain.py
37
37
  tests/test_log_util.py
38
+ tests/test_proxy_coverage.py
38
39
  tests/test_proxy_env.py
39
40
  tests/test_signed_policy.py
40
41
  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
@@ -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