conduct-cli 0.6.15__tar.gz → 0.6.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 (41) hide show
  1. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/PKG-INFO +1 -1
  2. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/pyproject.toml +1 -1
  3. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/guard.py +135 -13
  4. conduct_cli-0.6.16/src/conduct_cli/hooks/__init__.py +1 -0
  5. conduct_cli-0.6.16/src/conduct_cli/hooks/base.py +220 -0
  6. conduct_cli-0.6.16/src/conduct_cli/hooks/posttooluse.py +364 -0
  7. conduct_cli-0.6.16/src/conduct_cli/hooks/precompact.py +77 -0
  8. conduct_cli-0.6.16/src/conduct_cli/hooks/pretooluse.py +473 -0
  9. conduct_cli-0.6.16/src/conduct_cli/hooks/session_start.py +143 -0
  10. conduct_cli-0.6.16/src/conduct_cli/hooks/stop.py +30 -0
  11. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  12. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/SOURCES.txt +7 -0
  13. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/README.md +0 -0
  14. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/setup.cfg +0 -0
  15. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/setup.py +0 -0
  16. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/__init__.py +0 -0
  17. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/api.py +0 -0
  18. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/guardmcp.py +0 -0
  19. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/hook_precompact_template.py +0 -0
  20. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/hook_session_start_template.py +0 -0
  21. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/hook_stop_template.py +0 -0
  22. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/hook_template.py +0 -0
  23. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/log_util.py +0 -0
  24. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/main.py +0 -0
  25. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/mcp_server.py +0 -0
  26. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/memory.py +0 -0
  27. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli/paxel.py +0 -0
  28. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  29. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  30. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/requires.txt +0 -0
  31. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/src/conduct_cli.egg-info/top_level.txt +0 -0
  32. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_bash_operator_signature.py +0 -0
  33. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_command_word_matcher.py +0 -0
  34. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_guard_policy.py +0 -0
  35. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_guard_savings.py +0 -0
  36. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_hook_syntax.py +0 -0
  37. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_journal_drain.py +0 -0
  38. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_log_util.py +0 -0
  39. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_proxy_env.py +0 -0
  40. {conduct_cli-0.6.15 → conduct_cli-0.6.16}/tests/test_signed_policy.py +0 -0
  41. {conduct_cli-0.6.15 → conduct_cli-0.6.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.6.15
3
+ Version: 0.6.16
4
4
  Summary: CLI for Conduct AI — 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.6.15"
7
+ version = "0.6.16"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -28,6 +28,40 @@ def _read_template(name: str) -> str:
28
28
  return (_TEMPLATES_DIR / name).read_text()
29
29
 
30
30
 
31
+ # ── Thin launcher content ─────────────────────────────────────────────────────
32
+
33
+ _THIN_LAUNCHERS = {
34
+ "pretooluse": (
35
+ "#!/usr/bin/env python3\n"
36
+ "from conduct_cli.hooks.pretooluse import main; main()\n"
37
+ ),
38
+ "posttooluse": (
39
+ "#!/usr/bin/env python3\n"
40
+ "from conduct_cli.hooks.posttooluse import main; main()\n"
41
+ ),
42
+ "stop": (
43
+ "#!/usr/bin/env python3\n"
44
+ "from conduct_cli.hooks.stop import main; main()\n"
45
+ ),
46
+ "precompact": (
47
+ "#!/usr/bin/env python3\n"
48
+ "from conduct_cli.hooks.precompact import main; main()\n"
49
+ ),
50
+ "session-start": (
51
+ "#!/usr/bin/env python3\n"
52
+ "from conduct_cli.hooks.session_start import main; main()\n"
53
+ ),
54
+ }
55
+
56
+ # Detect whether an installed hook is already a thin launcher (contains the import)
57
+ def _is_thin_launcher(path: Path) -> bool:
58
+ try:
59
+ text = path.read_text()
60
+ return "from conduct_cli.hooks." in text
61
+ except Exception:
62
+ return False
63
+
64
+
31
65
 
32
66
 
33
67
  # ── Policy engine (also embedded in hook_template.py for standalone use) ──────
@@ -139,16 +173,31 @@ def _best_python() -> str:
139
173
  # ── Hook write helper ─────────────────────────────────────────────────────────
140
174
 
141
175
  def _write_hook(path: Path) -> None:
142
- """Write hook_template.py to path, then py_compile-validate it.
176
+ """Write a thin launcher to path (or legacy template for backward compat), then validate.
177
+
178
+ Thin launcher (new default):
179
+ #!/usr/bin/env python3
180
+ from conduct_cli.hooks.pretooluse import main; main()
181
+
182
+ If conduct_cli.hooks is not importable (e.g. editable install not set up),
183
+ falls back to writing the full template so hooks still work.
143
184
  On syntax failure: restores previous hook (or writes a safe stub) so the
144
- system is never left without a working hook file."""
145
- import py_compile, tempfile, os
146
- # Stash existing hook so we can restore on failure
185
+ system is never left without a working hook file.
186
+ """
187
+ import py_compile
147
188
  backup = None
148
189
  if path.exists():
149
190
  backup = path.read_text()
150
191
  path.parent.mkdir(parents=True, exist_ok=True)
151
- path.write_text(_read_template("hook_template.py"))
192
+
193
+ # Prefer thin launcher; fall back to full template if package not importable
194
+ try:
195
+ import conduct_cli.hooks.pretooluse # noqa: F401
196
+ content = _THIN_LAUNCHERS["pretooluse"]
197
+ except ImportError:
198
+ content = _read_template("hook_template.py")
199
+
200
+ path.write_text(content)
152
201
  path.chmod(0o755)
153
202
  try:
154
203
  py_compile.compile(str(path), doraise=True)
@@ -162,20 +211,33 @@ def _write_hook(path: Path) -> None:
162
211
  ) from exc
163
212
 
164
213
 
214
+ def _write_session_hook(path: Path, launcher_key: str, template_name: str) -> None:
215
+ """Write a thin launcher (or legacy template) for a session hook.
216
+
217
+ If conduct_cli.hooks is importable, writes the thin launcher.
218
+ Falls back to the full template so old installs continue to work.
219
+ Also rewrites old-style (non-thin) hooks to thin launchers on sync.
220
+ """
221
+ try:
222
+ import conduct_cli.hooks # noqa: F401
223
+ content = _THIN_LAUNCHERS[launcher_key]
224
+ except ImportError:
225
+ content = _read_template(template_name)
226
+ path.write_text(content)
227
+ path.chmod(0o755)
228
+
229
+
165
230
  def _install_session_hooks() -> None:
166
231
  """Write PreCompact + SessionStart + Stop hook scripts and register them in ~/.claude/settings.json."""
167
232
  python = _best_python()
168
233
 
169
- precompact_path = GUARD_DIR / "guard-precompact.py"
234
+ precompact_path = GUARD_DIR / "guard-precompact.py"
170
235
  session_start_path = GUARD_DIR / "guard-session-start.py"
171
- stop_path = GUARD_DIR / "guard-stop.py"
236
+ stop_path = GUARD_DIR / "guard-stop.py"
172
237
 
173
- precompact_path.write_text(_read_template("hook_precompact_template.py"))
174
- precompact_path.chmod(0o755)
175
- session_start_path.write_text(_read_template("hook_session_start_template.py"))
176
- session_start_path.chmod(0o755)
177
- stop_path.write_text(_read_template("hook_stop_template.py"))
178
- stop_path.chmod(0o755)
238
+ _write_session_hook(precompact_path, "precompact", "hook_precompact_template.py")
239
+ _write_session_hook(session_start_path, "session-start", "hook_session_start_template.py")
240
+ _write_session_hook(stop_path, "stop", "hook_stop_template.py")
179
241
 
180
242
  claude_settings = Path.home() / ".claude" / "settings.json"
181
243
  settings: dict = {}
@@ -1793,6 +1855,17 @@ def register_guard_parser(sub):
1793
1855
  # conduct guard skip-setup
1794
1856
  guard_sub.add_parser("skip-setup", help="Suppress the Guard setup reminder (does not disable Guard)")
1795
1857
 
1858
+ # conduct guard debug-hook <toolname>
1859
+ debug_hook_p = guard_sub.add_parser(
1860
+ "debug-hook",
1861
+ help="Run any hook standalone with JSON from stdin and print what it would do",
1862
+ )
1863
+ debug_hook_p.add_argument(
1864
+ "toolname",
1865
+ choices=["pretooluse", "posttooluse", "stop", "precompact", "session-start"],
1866
+ help="Which hook to test",
1867
+ )
1868
+
1796
1869
  return guard_p, guard_sub
1797
1870
 
1798
1871
 
@@ -1889,6 +1962,53 @@ def cmd_guard_booster_status(args):
1889
1962
  print()
1890
1963
 
1891
1964
 
1965
+ def cmd_guard_debug_hook(args):
1966
+ """Run a hook module standalone with JSON piped from stdin and print the outcome.
1967
+
1968
+ Usage:
1969
+ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | \\
1970
+ conduct guard debug-hook pretooluse
1971
+ """
1972
+ import subprocess as _sp
1973
+ toolname = args.toolname # pretooluse | posttooluse | stop | precompact | session-start
1974
+
1975
+ module_map = {
1976
+ "pretooluse": "conduct_cli.hooks.pretooluse",
1977
+ "posttooluse": "conduct_cli.hooks.posttooluse",
1978
+ "stop": "conduct_cli.hooks.stop",
1979
+ "precompact": "conduct_cli.hooks.precompact",
1980
+ "session-start":"conduct_cli.hooks.session_start",
1981
+ }
1982
+ module = module_map[toolname]
1983
+
1984
+ print(f"{BOLD}conduct guard debug-hook {toolname}{RESET}")
1985
+ print(f"{GRAY}Reading JSON from stdin (send EOF when done — Ctrl+D)…{RESET}\n")
1986
+
1987
+ stdin_data = sys.stdin.read()
1988
+ if not stdin_data.strip():
1989
+ # Provide a neutral no-op payload so the hook doesn't crash
1990
+ stdin_data = "{}"
1991
+
1992
+ result = _sp.run(
1993
+ [sys.executable, "-c", f"from {module} import main; main()"],
1994
+ input=stdin_data,
1995
+ capture_output=True,
1996
+ text=True,
1997
+ )
1998
+
1999
+ if result.stdout:
2000
+ print(f"{BOLD}stdout:{RESET}")
2001
+ print(result.stdout)
2002
+ if result.stderr:
2003
+ print(f"{BOLD}stderr:{RESET}")
2004
+ print(result.stderr)
2005
+
2006
+ exit_label = {0: f"{GREEN}0 — allowed{RESET}", 2: f"{RED}2 — blocked{RESET}"}.get(
2007
+ result.returncode, f"{YELLOW}{result.returncode}{RESET}"
2008
+ )
2009
+ print(f"\n{BOLD}Exit code:{RESET} {exit_label}")
2010
+
2011
+
1892
2012
  def dispatch_guard(args, guard_p):
1893
2013
  """Dispatch to the correct guard handler. Called from main()."""
1894
2014
  guard_command = getattr(args, "guard_command", None)
@@ -1904,6 +2024,8 @@ def dispatch_guard(args, guard_p):
1904
2024
  cmd_guard_install(args)
1905
2025
  elif guard_command == "booster-status":
1906
2026
  cmd_guard_booster_status(args)
2027
+ elif guard_command == "debug-hook":
2028
+ cmd_guard_debug_hook(args)
1907
2029
  else:
1908
2030
  guard_p.print_help()
1909
2031
  sys.exit(1)
@@ -0,0 +1 @@
1
+ # ConductGuard hook package — shared utilities + per-hook entrypoints.
@@ -0,0 +1,220 @@
1
+ """Shared primitives for all ConductGuard hook modules.
2
+
3
+ Everything here is stdlib-only so hooks work from a bare `pip install conduct-cli`
4
+ with no shell rc sourced.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Literal, Optional
16
+
17
+ # ── Canonical paths ───────────────────────────────────────────────────────────
18
+
19
+ GUARD_DIR = Path.home() / ".conductguard"
20
+ CONFIG_PATH = GUARD_DIR / "config.json"
21
+ POLICY_PATH = GUARD_DIR / "policy.json"
22
+ BUDGET_CACHE_PATH = GUARD_DIR / "budget_cache.json"
23
+ BUDGET_CACHE_TTL = 300 # seconds
24
+ VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
25
+ VERSION_CACHE_TTL = 60 # seconds
26
+ WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
27
+ SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
28
+ JOURNAL_DIR = GUARD_DIR / "journal"
29
+ JOURNAL_PID_PATH = JOURNAL_DIR / "drain.pid"
30
+
31
+ SNAPSHOT_PATH = GUARD_DIR / "session_snapshot.json"
32
+ CONDUCT_ENV_PATH = Path.home() / ".conduct" / "env"
33
+
34
+ # ── Result type ───────────────────────────────────────────────────────────────
35
+
36
+ @dataclass
37
+ class HookResult:
38
+ action: Literal["allow", "block", "warn"]
39
+ reason: Optional[str] = None
40
+ metadata: dict = field(default_factory=dict)
41
+
42
+
43
+ # ── Config loading ────────────────────────────────────────────────────────────
44
+
45
+ def load_config() -> dict:
46
+ """Return ~/.conductguard/config.json as a dict; empty dict on any error."""
47
+ try:
48
+ if CONFIG_PATH.exists():
49
+ return json.loads(CONFIG_PATH.read_text())
50
+ except Exception:
51
+ pass
52
+ return {}
53
+
54
+
55
+ # ── Repo / tool detection ─────────────────────────────────────────────────────
56
+
57
+ def detect_repo() -> Optional[str]:
58
+ """Return 'owner/repo' from git remote origin, or None."""
59
+ try:
60
+ out = subprocess.check_output(
61
+ ["git", "remote", "get-url", "origin"],
62
+ stderr=subprocess.DEVNULL, text=True,
63
+ ).strip()
64
+ if "github.com" in out:
65
+ return out.split("github.com")[-1].lstrip("/:").rstrip(".git")
66
+ except Exception:
67
+ pass
68
+ return None
69
+
70
+
71
+ def detect_ai_tool() -> str:
72
+ """Return a string identifying the active AI coding tool."""
73
+ import os
74
+ if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
75
+ return "claude-code"
76
+ if os.environ.get("CODEX_SESSION_ID") or os.environ.get("CODEX_CLI_VERSION"):
77
+ return "codex"
78
+ term = os.environ.get("TERM_PROGRAM", "").lower()
79
+ if term == "cursor":
80
+ return "cursor"
81
+ if term == "windsurf":
82
+ return "windsurf"
83
+ path = os.environ.get("PATH", "")
84
+ if "Codex.app" in path or "codex" in path.lower():
85
+ return "codex"
86
+ if "cursor" in path.lower():
87
+ return "cursor"
88
+ if "windsurf" in path.lower():
89
+ return "windsurf"
90
+ return "claude-code"
91
+
92
+
93
+ # ── Journal / drain (fire-and-forget event posting) ───────────────────────────
94
+
95
+ def journal_append(payload_str: str, api_url: str) -> None:
96
+ """Atomically write one event to the journal for the drain daemon to pick up."""
97
+ try:
98
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
99
+ import random
100
+ name = f"{time.time_ns()}_{random.randint(0, 9999):04d}.json"
101
+ entry = json.dumps({"api_url": api_url, "payload": payload_str})
102
+ tmp = JOURNAL_DIR / (name + ".tmp")
103
+ tmp.write_text(entry)
104
+ tmp.rename(JOURNAL_DIR / name)
105
+ except Exception:
106
+ pass
107
+
108
+
109
+ def ensure_drain_daemon(hook_module_path: Optional[Path] = None) -> None:
110
+ """Start the drain daemon if it is not already running.
111
+
112
+ hook_module_path — path of the calling hook file (used to spawn the daemon
113
+ via `python <path> drain`). When None the drain subprocess is not started
114
+ (used in unit tests / debug mode where the daemon is not needed).
115
+ """
116
+ try:
117
+ if JOURNAL_PID_PATH.exists():
118
+ pid = int(JOURNAL_PID_PATH.read_text().strip())
119
+ import os as _os
120
+ try:
121
+ _os.kill(pid, 0)
122
+ return # alive
123
+ except (ProcessLookupError, PermissionError):
124
+ pass
125
+ if hook_module_path is None:
126
+ return
127
+ subprocess.Popen(
128
+ [sys.executable, str(hook_module_path), "drain"],
129
+ stdout=subprocess.DEVNULL,
130
+ stderr=subprocess.DEVNULL,
131
+ start_new_session=True,
132
+ )
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ def run_drain_daemon() -> None:
138
+ """Drain the journal directory to the API. Call via `python hook.py drain`."""
139
+ import os as _os
140
+ try:
141
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
142
+ JOURNAL_PID_PATH.write_text(str(_os.getpid()))
143
+ except Exception:
144
+ return
145
+ empty_scans = 0
146
+ while empty_scans < 3:
147
+ files = sorted(f for f in JOURNAL_DIR.glob("*.json") if f.name != "drain.pid")
148
+ if not files:
149
+ empty_scans += 1
150
+ time.sleep(2)
151
+ continue
152
+ posted_any = False
153
+ for f in files:
154
+ try:
155
+ entry = json.loads(f.read_text())
156
+ api_url = entry["api_url"]
157
+ payload = (
158
+ entry["payload"].encode()
159
+ if isinstance(entry["payload"], str)
160
+ else entry["payload"]
161
+ )
162
+ req = urllib.request.Request(
163
+ f"{api_url}/guard/events",
164
+ data=payload,
165
+ headers={"Content-Type": "application/json"},
166
+ method="POST",
167
+ )
168
+ urllib.request.urlopen(req, timeout=8)
169
+ f.unlink(missing_ok=True)
170
+ posted_any = True
171
+ except Exception:
172
+ pass
173
+ if not posted_any:
174
+ empty_scans += 1
175
+ time.sleep(2)
176
+ else:
177
+ empty_scans = 0
178
+ try:
179
+ JOURNAL_PID_PATH.unlink(missing_ok=True)
180
+ except Exception:
181
+ pass
182
+
183
+
184
+ def post_event(
185
+ tool_name: str,
186
+ tool_input: dict,
187
+ decision: str,
188
+ rule_id: Optional[str] = None,
189
+ message: Optional[str] = None,
190
+ session_id: Optional[str] = None,
191
+ *,
192
+ drain_via: Optional[Path] = None,
193
+ ) -> None:
194
+ """Post one guard event via the journal/drain pattern. Never raises.
195
+
196
+ drain_via — path of the calling hook file passed to ensure_drain_daemon().
197
+ """
198
+ cfg = load_config()
199
+ workspace_id = cfg.get("workspace_id")
200
+ if not workspace_id:
201
+ return
202
+ import platform as _platform
203
+ _os_info = f"{_platform.system()} {_platform.release()} {_platform.machine()}".strip()
204
+ payload = json.dumps({
205
+ "workspace_id": workspace_id,
206
+ "clerk_user_id": cfg.get("user_email"),
207
+ "user_email": cfg.get("user_email"),
208
+ "ai_tool": detect_ai_tool(),
209
+ "tool_call": tool_name,
210
+ "input_summary": json.dumps(tool_input)[:200],
211
+ "decision": decision,
212
+ "rule_id": rule_id,
213
+ "rule_message": message,
214
+ "hook_session_id": session_id,
215
+ "os_info": _os_info,
216
+ "hostname": _platform.node(),
217
+ })
218
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
219
+ journal_append(payload, api_url)
220
+ ensure_drain_daemon(drain_via)