conduct-cli 0.7.7__tar.gz → 0.7.9__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.7 → conduct_cli-0.7.9}/PKG-INFO +1 -1
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/pyproject.toml +1 -1
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/base.py +1 -1
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.7.9/tests/test_hook_dispatch.py +215 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/README.md +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/setup.cfg +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/setup.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/guard.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_hook_syntax.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.7 → conduct_cli-0.7.9}/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.9"
|
|
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" }
|
|
@@ -227,7 +227,7 @@ def post_event(
|
|
|
227
227
|
"clerk_user_id": cfg.get("user_email"),
|
|
228
228
|
"user_email": cfg.get("user_email"),
|
|
229
229
|
"ai_tool": detect_ai_tool(),
|
|
230
|
-
"tool_call": tool_name,
|
|
230
|
+
"tool_call": tool_name[:255],
|
|
231
231
|
"input_summary": _safe_summary(tool_input),
|
|
232
232
|
"decision": decision,
|
|
233
233
|
"rule_id": rule_id,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Regression tests for hook dispatch and entrypoint wiring.
|
|
2
|
+
|
|
3
|
+
Catches the class of bug where a refactor silently breaks:
|
|
4
|
+
- hook.py routing (post → posttooluse, else → pretooluse)
|
|
5
|
+
- hook module importability
|
|
6
|
+
- pyproject.toml entrypoint resolution
|
|
7
|
+
- token backfill return types
|
|
8
|
+
- journal payload structure
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import textwrap
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from unittest.mock import MagicMock, patch
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
HOOKS_DIR = Path(__file__).parent.parent / "src" / "conduct_cli" / "hooks"
|
|
22
|
+
GUARD_DIR = Path(__file__).parent.parent / "src" / "conduct_cli"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── 1. hook.py dispatch ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def _hook_py_content() -> str:
|
|
28
|
+
"""Read the thin-launcher template that gets written to ~/.conductguard/hook.py."""
|
|
29
|
+
from conduct_cli.guard import _THIN_LAUNCHERS
|
|
30
|
+
return _THIN_LAUNCHERS["pretooluse"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_hook_py_dispatches_post_to_posttooluse():
|
|
34
|
+
content = _hook_py_content()
|
|
35
|
+
assert "post" in content, "hook.py must branch on 'post' arg"
|
|
36
|
+
assert "posttooluse" in content, "hook.py must route 'post' to posttooluse"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_hook_py_dispatches_default_to_pretooluse():
|
|
40
|
+
content = _hook_py_content()
|
|
41
|
+
assert "pretooluse" in content, "hook.py must route default to pretooluse"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_hook_py_is_valid_python():
|
|
45
|
+
import py_compile, tempfile, os
|
|
46
|
+
content = _hook_py_content()
|
|
47
|
+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
|
|
48
|
+
f.write(content)
|
|
49
|
+
name = f.name
|
|
50
|
+
try:
|
|
51
|
+
py_compile.compile(name, doraise=True)
|
|
52
|
+
finally:
|
|
53
|
+
os.unlink(name)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── 2. All hook modules importable ───────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize("module", [
|
|
59
|
+
"conduct_cli.hooks.pretooluse",
|
|
60
|
+
"conduct_cli.hooks.posttooluse",
|
|
61
|
+
"conduct_cli.hooks.stop",
|
|
62
|
+
"conduct_cli.hooks.session_report_push",
|
|
63
|
+
"conduct_cli.hooks.base",
|
|
64
|
+
])
|
|
65
|
+
def test_hook_module_importable(module):
|
|
66
|
+
mod = importlib.import_module(module)
|
|
67
|
+
assert mod is not None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize("module,fn", [
|
|
71
|
+
("conduct_cli.hooks.pretooluse", "main"),
|
|
72
|
+
("conduct_cli.hooks.posttooluse", "main"),
|
|
73
|
+
("conduct_cli.hooks.stop", "main"),
|
|
74
|
+
("conduct_cli.hooks.session_report_push", "run"),
|
|
75
|
+
])
|
|
76
|
+
def test_hook_entrypoint_callable(module, fn):
|
|
77
|
+
mod = importlib.import_module(module)
|
|
78
|
+
assert callable(getattr(mod, fn, None)), f"{module}:{fn} must be callable"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── 3. pyproject.toml entrypoints resolve ────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def _parse_entrypoints() -> dict[str, str]:
|
|
84
|
+
"""Read [project.scripts] from pyproject.toml."""
|
|
85
|
+
import tomllib # Python 3.11+; fall back to tomli
|
|
86
|
+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
|
|
87
|
+
try:
|
|
88
|
+
with open(pyproject, "rb") as f:
|
|
89
|
+
data = tomllib.load(f)
|
|
90
|
+
except ImportError:
|
|
91
|
+
try:
|
|
92
|
+
import tomli
|
|
93
|
+
with open(pyproject, "rb") as f:
|
|
94
|
+
data = tomli.load(f)
|
|
95
|
+
except ImportError:
|
|
96
|
+
pytest.skip("tomllib/tomli not available — skipping entrypoint check")
|
|
97
|
+
return data.get("project", {}).get("scripts", {})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_conductguard_post_entrypoint_resolves():
|
|
101
|
+
eps = _parse_entrypoints()
|
|
102
|
+
target = eps.get("conductguard-post", "")
|
|
103
|
+
assert target, "conductguard-post must be in [project.scripts]"
|
|
104
|
+
module_path, fn_name = target.rsplit(":", 1)
|
|
105
|
+
mod = importlib.import_module(module_path)
|
|
106
|
+
assert callable(getattr(mod, fn_name, None)), (
|
|
107
|
+
f"conductguard-post points to {target} which is not callable. "
|
|
108
|
+
"Did a refactor move the function without updating pyproject.toml?"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_conduct_entrypoint_resolves():
|
|
113
|
+
eps = _parse_entrypoints()
|
|
114
|
+
target = eps.get("conduct", "")
|
|
115
|
+
assert target, "conduct must be in [project.scripts]"
|
|
116
|
+
module_path, fn_name = target.rsplit(":", 1)
|
|
117
|
+
mod = importlib.import_module(module_path)
|
|
118
|
+
assert callable(getattr(mod, fn_name, None))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_conductguard_mcp_entrypoint_resolves():
|
|
122
|
+
eps = _parse_entrypoints()
|
|
123
|
+
for name in ("conduct-mcp", "conductguard-mcp"):
|
|
124
|
+
target = eps.get(name, "")
|
|
125
|
+
if not target:
|
|
126
|
+
continue
|
|
127
|
+
module_path, fn_name = target.rsplit(":", 1)
|
|
128
|
+
mod = importlib.import_module(module_path)
|
|
129
|
+
assert callable(getattr(mod, fn_name, None)), f"{name} entrypoint broken"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── 4. Token backfill returns correct types ──────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def test_read_tokens_returns_ints_on_missing_file():
|
|
135
|
+
from conduct_cli.hooks.posttooluse import _read_tokens_from_transcript
|
|
136
|
+
tin, tout = _read_tokens_from_transcript("/nonexistent/path.jsonl", "tool_use_abc123")
|
|
137
|
+
assert isinstance(tin, int), "tokens_input must be int"
|
|
138
|
+
assert isinstance(tout, int), "tokens_output must be int"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_read_tokens_returns_ints_on_no_match(tmp_path):
|
|
142
|
+
from conduct_cli.hooks.posttooluse import _read_tokens_from_transcript
|
|
143
|
+
f = tmp_path / "session.jsonl"
|
|
144
|
+
f.write_text(json.dumps({"type": "text", "content": "hello"}) + "\n")
|
|
145
|
+
tin, tout = _read_tokens_from_transcript(str(f), "tool_use_xyz")
|
|
146
|
+
assert isinstance(tin, int)
|
|
147
|
+
assert isinstance(tout, int)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_read_tokens_extracts_from_valid_transcript(tmp_path):
|
|
151
|
+
from conduct_cli.hooks.posttooluse import _read_tokens_from_transcript
|
|
152
|
+
tool_use_id = "toolu_01abc"
|
|
153
|
+
entry = {
|
|
154
|
+
"message": {
|
|
155
|
+
"usage": {"input_tokens": 1500, "output_tokens": 200,
|
|
156
|
+
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 100},
|
|
157
|
+
"content": [{"type": "tool_use", "id": tool_use_id, "name": "read"}],
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
f = tmp_path / "session.jsonl"
|
|
161
|
+
f.write_text(json.dumps(entry) + "\n")
|
|
162
|
+
tin, tout = _read_tokens_from_transcript(str(f), tool_use_id)
|
|
163
|
+
assert tin == 1600 # input + cache_read
|
|
164
|
+
assert tout == 200
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── 5. Journal payload structure ─────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
def test_post_event_tool_call_fits_db_column(tmp_path):
|
|
170
|
+
"""tool_call in journal payload must not exceed 255 chars (DB column limit)."""
|
|
171
|
+
from conduct_cli.hooks import base
|
|
172
|
+
|
|
173
|
+
journal_dir = tmp_path / "journal"
|
|
174
|
+
cfg = {"workspace_id": "test-ws", "user_email": "x@x.com", "api_url": "https://api.conductai.ai"}
|
|
175
|
+
long_name = "mcp__" + "x" * 260 # deliberately over 255
|
|
176
|
+
|
|
177
|
+
with (
|
|
178
|
+
patch.object(base, "JOURNAL_DIR", journal_dir),
|
|
179
|
+
patch.object(base, "JOURNAL_PID_PATH", journal_dir / "drain.pid"),
|
|
180
|
+
patch.object(base, "load_config", return_value=cfg),
|
|
181
|
+
patch.object(base, "ensure_drain_daemon"),
|
|
182
|
+
):
|
|
183
|
+
base.post_event(long_name, {}, "allowed", session_id="s")
|
|
184
|
+
|
|
185
|
+
files = list(journal_dir.glob("*.json"))
|
|
186
|
+
payload = json.loads(json.loads(files[0].read_text())["payload"])
|
|
187
|
+
assert len(payload["tool_call"]) <= 255, "tool_call must be truncated to 255 chars"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_post_event_journal_payload_has_required_fields(tmp_path):
|
|
191
|
+
"""post_event must journal a payload with workspace_id and hook_session_id."""
|
|
192
|
+
from conduct_cli.hooks import base
|
|
193
|
+
|
|
194
|
+
journal_dir = tmp_path / "journal"
|
|
195
|
+
cfg = {
|
|
196
|
+
"workspace_id": "test-ws-id",
|
|
197
|
+
"user_email": "test@example.com",
|
|
198
|
+
"api_url": "https://api.conductai.ai",
|
|
199
|
+
}
|
|
200
|
+
with (
|
|
201
|
+
patch.object(base, "JOURNAL_DIR", journal_dir),
|
|
202
|
+
patch.object(base, "JOURNAL_PID_PATH", journal_dir / "drain.pid"),
|
|
203
|
+
patch.object(base, "load_config", return_value=cfg),
|
|
204
|
+
patch.object(base, "ensure_drain_daemon"),
|
|
205
|
+
):
|
|
206
|
+
base.post_event("bash", {"command": "ls"}, "allowed", session_id="sess-123")
|
|
207
|
+
|
|
208
|
+
files = list(journal_dir.glob("*.json"))
|
|
209
|
+
assert files, "post_event must write to journal"
|
|
210
|
+
entry = json.loads(files[0].read_text())
|
|
211
|
+
payload = json.loads(entry["payload"])
|
|
212
|
+
assert payload["workspace_id"] == "test-ws-id"
|
|
213
|
+
assert payload["hook_session_id"] == "sess-123"
|
|
214
|
+
assert payload["decision"] == "allowed"
|
|
215
|
+
assert "user_email" in payload
|
|
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
|
|
File without changes
|