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.
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/PKG-INFO +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/pyproject.toml +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/guardmcp.py +20 -7
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/base.py +19 -5
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/pretooluse.py +6 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_advisory_and_hook.py +62 -0
- conduct_cli-0.7.16/tests/test_proxy_coverage.py +79 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/README.md +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/setup.cfg +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/setup.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.16}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
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
|
-
|
|
187
|
-
|
|
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 "
|
|
192
|
+
return "claude-desktop" # Claude for Work / Teams
|
|
190
193
|
if "claude" in name:
|
|
191
|
-
return "
|
|
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
|
-
|
|
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("
|
|
82
|
-
return "
|
|
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
|
|
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:
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|