conduct-cli 0.5.7__tar.gz → 0.5.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.
Files changed (30) hide show
  1. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/PKG-INFO +1 -1
  2. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/pyproject.toml +1 -1
  3. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/guard.py +6 -1
  4. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/guardmcp.py +4 -0
  5. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_template.py +36 -1
  6. conduct_cli-0.5.9/src/conduct_cli/log_util.py +180 -0
  7. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/main.py +4 -0
  8. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/mcp_server.py +4 -0
  9. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  10. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  11. conduct_cli-0.5.9/tests/test_log_util.py +123 -0
  12. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/README.md +0 -0
  13. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/setup.cfg +0 -0
  14. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/setup.py +0 -0
  15. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/__init__.py +0 -0
  16. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/api.py +0 -0
  17. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_precompact_template.py +0 -0
  18. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_session_start_template.py +0 -0
  19. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/hook_stop_template.py +0 -0
  20. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/memory.py +0 -0
  21. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli/paxel.py +0 -0
  22. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  23. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  24. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/requires.txt +0 -0
  25. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
  26. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_bash_operator_signature.py +0 -0
  27. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_guard_policy.py +0 -0
  28. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_guard_savings.py +0 -0
  29. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_hook_syntax.py +0 -0
  30. {conduct_cli-0.5.7 → conduct_cli-0.5.9}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
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.5.7"
7
+ version = "0.5.9"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -572,8 +572,13 @@ def cmd_guard_install(args):
572
572
  api_key=api_key,
573
573
  )
574
574
  _save_policy(policy)
575
+ # Mirror fail_mode into guard config so the hook can enforce it
576
+ # even when POLICY_PATH is missing or unreadable.
577
+ cfg = _load_guard_config()
578
+ cfg["fail_mode"] = policy.get("fail_mode", "fail_open")
579
+ _save_guard_config(cfg)
575
580
  rule_count = len(policy.get("rules", []))
576
- print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active")
581
+ print(f" {GREEN}Guard policies:{RESET} {rule_count} rule(s) active · fail mode: {cfg['fail_mode']}")
577
582
  except SystemExit:
578
583
  rule_count = 0
579
584
 
@@ -467,5 +467,9 @@ def main() -> None:
467
467
  _err(msg_id, -32601, f"Method not found: {method}")
468
468
 
469
469
 
470
+ # ponytail: telemetry error interceptor (#718)
471
+ from .log_util import install_error_interceptor as _ei
472
+ main = _ei(main)
473
+
470
474
  if __name__ == "__main__":
471
475
  main()
@@ -86,7 +86,17 @@ def _maybe_sync_policy():
86
86
  POLICY_PATH.write_text(json.dumps(remote, indent=2))
87
87
  VERSION_CACHE_PATH.write_text(json.dumps({"ts": time.time(), "version": remote_version}))
88
88
  except Exception:
89
- pass # Never block a tool call due to sync failure
89
+ pass # sync failure does not directly block; policy file may still be present
90
+
91
+
92
+ def _get_fail_mode():
93
+ """Read fail_mode from ~/.conduct/config.json. Default: fail_open."""
94
+ try:
95
+ cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
96
+ except Exception:
97
+ return "fail_open"
98
+ mode = cfg.get("fail_mode", "fail_open")
99
+ return mode if mode in ("fail_open", "fail_closed") else "fail_open"
90
100
 
91
101
 
92
102
  def _load_budget_cache():
@@ -685,10 +695,35 @@ def main():
685
695
  # Policy version check (cached 60s) — auto-syncs if server version differs
686
696
  _maybe_sync_policy()
687
697
 
698
+ # Fail-closed gate: if the local policy file is missing and the workspace
699
+ # is configured for fail_closed, block every tool call with a clear
700
+ # message. fail_open (default) silently allows.
701
+ if _get_fail_mode() == "fail_closed" and not POLICY_PATH.exists():
702
+ tool_name = (data.get("tool_name") or "").lower()
703
+ tool_input = data.get("tool_input") or {}
704
+ session_id = data.get("session_id")
705
+ msg = "[ConductGuard] Policy cache unavailable — fail-closed mode is on. Run `conduct guard sync` or contact your admin."
706
+ print(msg)
707
+ print(msg, file=sys.stderr)
708
+ _post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id=session_id)
709
+ sys.exit(2)
710
+
688
711
  # Hard budget cap (cached 5 min)
689
712
  hard_blocked, reason = _load_budget_cache()
690
713
  if hard_blocked is None:
691
714
  hard_blocked, reason = _fetch_budget_status()
715
+ # Fail-closed: if the budget check returned default (None reason) AND
716
+ # we just hit the API for a fresh value, the API was unreachable.
717
+ # Treat as unavailable and block.
718
+ if not hard_blocked and reason is None and _get_fail_mode() == "fail_closed":
719
+ tool_name = (data.get("tool_name") or "").lower()
720
+ tool_input = data.get("tool_input") or {}
721
+ session_id = data.get("session_id")
722
+ msg = "[ConductGuard] Spend budget check unavailable — fail-closed mode is on."
723
+ print(msg)
724
+ print(msg, file=sys.stderr)
725
+ _post_event(tool_name, tool_input, "blocked", "guard-unavailable", msg, session_id=session_id)
726
+ sys.exit(2)
692
727
  if hard_blocked:
693
728
  msg = f"[ConductGuard] {reason or 'Budget hard cap reached. Contact your manager.'}"
694
729
  print(msg)
@@ -0,0 +1,180 @@
1
+ """Shared log helper + error interceptor for conduct-cli (#718 Phase 1).
2
+
3
+ Stdlib only. No new dependencies. Failures here MUST NEVER block the user —
4
+ telemetry is best-effort. The POST runs in a daemon thread with a join
5
+ timeout so even a hung endpoint can't delay CLI exit by more than ~2s.
6
+
7
+ Public API:
8
+ log(level, message, **context) # print + (warn/err) post
9
+ install_error_interceptor(main_fn) # wrap top-level entry point
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import threading
17
+ import traceback
18
+ import urllib.request
19
+ from datetime import datetime, timezone
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+ from typing import Callable
23
+
24
+ PREFIX = "[conduct]"
25
+ TOOL = "conduct-cli"
26
+
27
+ _COLORS = {
28
+ "info": "\033[37m",
29
+ "ok": "\033[32m",
30
+ "warning": "\033[33m",
31
+ "error": "\033[31m",
32
+ }
33
+ _RESET = "\033[0m"
34
+
35
+ _API_BASE = os.environ.get("CONDUCT_API_URL", "https://api.conductai.ai").rstrip("/")
36
+ _CONFIG = Path.home() / ".conductguard" / "config.json"
37
+ _LOG_FILE = Path(
38
+ os.environ.get("CONDUCT_LOG_FILE")
39
+ or str(Path.home() / ".conductguard" / "logs" / "events.jsonl")
40
+ )
41
+ _POST_TIMEOUT_S = 2.0
42
+
43
+ _MAX_BYTES = 10 * 1024 * 1024 # 10MB
44
+ _BACKUPS = 3 # keep events.jsonl.1, .2, .3
45
+
46
+
47
+ def _rotate() -> None:
48
+ """events.jsonl → .1 → .2 → .3; oldest dropped. Silent on failure."""
49
+ base = str(_LOG_FILE)
50
+ try:
51
+ oldest = Path(f"{base}.{_BACKUPS}")
52
+ if oldest.exists():
53
+ oldest.unlink()
54
+ for i in range(_BACKUPS - 1, 0, -1):
55
+ src = Path(f"{base}.{i}")
56
+ if src.exists():
57
+ src.replace(Path(f"{base}.{i + 1}"))
58
+ if _LOG_FILE.exists():
59
+ _LOG_FILE.replace(Path(f"{base}.1"))
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def _write_local(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
65
+ """Append one JSONL event to the local log file. Foreground, fast, silent on failure."""
66
+ try:
67
+ _LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
68
+ if _LOG_FILE.exists() and _LOG_FILE.stat().st_size >= _MAX_BYTES:
69
+ _rotate()
70
+ line = json.dumps({
71
+ "ts": datetime.now(timezone.utc).isoformat(),
72
+ "tool": TOOL,
73
+ "version": _version(),
74
+ "event_type": event_type,
75
+ "message": message,
76
+ "traceback": tb,
77
+ "run_id": os.environ.get("CONDUCT_RUN_ID"),
78
+ "session_id": os.environ.get("CONDUCT_SESSION_ID"),
79
+ "context": ctx or {},
80
+ })
81
+ with _LOG_FILE.open("a", encoding="utf-8") as f:
82
+ f.write(line + "\n")
83
+ except Exception:
84
+ pass # disk failure must never block the user
85
+
86
+
87
+ @lru_cache(maxsize=1)
88
+ def _read_creds() -> tuple[str | None, str | None]:
89
+ """(member_token, workspace_id) from ~/.conductguard/config.json, or (None, None)."""
90
+ try:
91
+ cfg = json.loads(_CONFIG.read_text())
92
+ return cfg.get("member_token"), cfg.get("workspace_id")
93
+ except Exception:
94
+ return None, None
95
+
96
+
97
+ @lru_cache(maxsize=1)
98
+ def _version() -> str:
99
+ try:
100
+ from importlib.metadata import version
101
+ return version("conduct-cli")
102
+ except Exception:
103
+ return "unknown"
104
+
105
+
106
+ def _post_event_sync(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
107
+ token, workspace_id = _read_creds()
108
+ if not token:
109
+ return # no creds = no telemetry, silent
110
+ body = json.dumps({
111
+ "tool": TOOL,
112
+ "version": _version(),
113
+ "event_type": event_type,
114
+ "message": message[:4000],
115
+ "traceback": tb,
116
+ "run_id": os.environ.get("CONDUCT_RUN_ID"),
117
+ "session_id": os.environ.get("CONDUCT_SESSION_ID"),
118
+ "context": ctx or {},
119
+ }).encode("utf-8")
120
+ headers = {
121
+ "Authorization": f"Bearer {token}",
122
+ "Content-Type": "application/json",
123
+ }
124
+ if workspace_id:
125
+ headers["X-Workspace-Id"] = workspace_id
126
+ try:
127
+ req = urllib.request.Request(
128
+ f"{_API_BASE}/telemetry/events",
129
+ data=body,
130
+ headers=headers,
131
+ method="POST",
132
+ )
133
+ urllib.request.urlopen(req, timeout=_POST_TIMEOUT_S)
134
+ except Exception:
135
+ pass # ponytail: silent — telemetry must never block the user
136
+
137
+
138
+ def _post_event(event_type: str, message: str, tb: str | None, ctx: dict, *, wait: bool = False) -> None:
139
+ """Fire-and-forget by default; wait=True for the error interceptor so the
140
+ POST has a chance to land before sys.exit."""
141
+ t = threading.Thread(
142
+ target=_post_event_sync,
143
+ args=(event_type, message, tb, ctx),
144
+ daemon=True,
145
+ name="conduct-telemetry",
146
+ )
147
+ t.start()
148
+ if wait:
149
+ t.join(timeout=_POST_TIMEOUT_S)
150
+
151
+
152
+ def log(level: str, msg: str, **ctx) -> None:
153
+ """Print to stderr with a colored prefix; warn/error also persist locally
154
+ AND post to telemetry. Same event, three destinations, written once each."""
155
+ color = _COLORS.get(level, "")
156
+ sys.stderr.write(f"{color}{PREFIX} {msg}{_RESET}\n")
157
+ if level in ("warning", "error"):
158
+ _write_local(level, msg, None, ctx) # foreground, always
159
+ _post_event(level, msg, None, ctx) # background, best-effort
160
+
161
+
162
+ def install_error_interceptor(main_fn: Callable) -> Callable:
163
+ """Wrap a CLI entry point. Unhandled exception → telemetry POST → stderr → exit 1.
164
+
165
+ SystemExit / KeyboardInterrupt pass through unchanged.
166
+ """
167
+ def wrapped(*args, **kwargs):
168
+ try:
169
+ return main_fn(*args, **kwargs)
170
+ except (SystemExit, KeyboardInterrupt):
171
+ raise
172
+ except Exception as e:
173
+ tb = traceback.format_exc()
174
+ msg = f"{type(e).__name__}: {e}"
175
+ _write_local("error", msg, tb, {}) # foreground, ensures file row before exit
176
+ _post_event("error", msg, tb, {}, wait=True) # background, best-effort
177
+ sys.stderr.write(f"{_COLORS['error']}{PREFIX} {msg}{_RESET}\n{tb}\n")
178
+ sys.exit(1)
179
+ wrapped.__wrapped__ = main_fn
180
+ return wrapped
@@ -3205,5 +3205,9 @@ def main():
3205
3205
  parser.print_help()
3206
3206
 
3207
3207
 
3208
+ # ponytail: telemetry error interceptor (#718)
3209
+ from .log_util import install_error_interceptor as _ei
3210
+ main = _ei(main)
3211
+
3208
3212
  if __name__ == "__main__":
3209
3213
  main()
@@ -330,5 +330,9 @@ def main() -> None:
330
330
  _err(msg_id, -32601, f"Method not found: {method}")
331
331
 
332
332
 
333
+ # ponytail: telemetry error interceptor (#718)
334
+ from .log_util import install_error_interceptor as _ei
335
+ main = _ei(main)
336
+
333
337
  if __name__ == "__main__":
334
338
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
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
@@ -9,6 +9,7 @@ src/conduct_cli/hook_precompact_template.py
9
9
  src/conduct_cli/hook_session_start_template.py
10
10
  src/conduct_cli/hook_stop_template.py
11
11
  src/conduct_cli/hook_template.py
12
+ src/conduct_cli/log_util.py
12
13
  src/conduct_cli/main.py
13
14
  src/conduct_cli/mcp_server.py
14
15
  src/conduct_cli/memory.py
@@ -23,4 +24,5 @@ tests/test_bash_operator_signature.py
23
24
  tests/test_guard_policy.py
24
25
  tests/test_guard_savings.py
25
26
  tests/test_hook_syntax.py
27
+ tests/test_log_util.py
26
28
  tests/test_switch.py
@@ -0,0 +1,123 @@
1
+ """Tests for conduct_cli.log_util — error interceptor + post path (#718)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from conduct_cli import log_util
7
+
8
+
9
+ def test_interceptor_catches_exception_and_posts(monkeypatch):
10
+ captured = {}
11
+
12
+ def _fake_post(event_type, message, tb, ctx, *, wait=False):
13
+ captured["event_type"] = event_type
14
+ captured["message"] = message
15
+ captured["tb"] = tb
16
+ captured["wait"] = wait
17
+
18
+ monkeypatch.setattr(log_util, "_post_event", _fake_post)
19
+
20
+ def _boom():
21
+ raise ValueError("kaboom")
22
+
23
+ wrapped = log_util.install_error_interceptor(_boom)
24
+ with pytest.raises(SystemExit) as ex:
25
+ wrapped()
26
+ assert ex.value.code == 1
27
+ assert captured["event_type"] == "error"
28
+ assert "ValueError" in captured["message"]
29
+ assert "kaboom" in captured["message"]
30
+ assert captured["tb"] is not None
31
+ assert captured["wait"] is True
32
+
33
+
34
+ def test_interceptor_passes_through_systemexit(monkeypatch):
35
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
36
+
37
+ def _exit_clean():
38
+ raise SystemExit(0)
39
+
40
+ wrapped = log_util.install_error_interceptor(_exit_clean)
41
+ with pytest.raises(SystemExit) as ex:
42
+ wrapped()
43
+ assert ex.value.code == 0
44
+
45
+
46
+ def test_post_skipped_when_no_creds(monkeypatch, tmp_path):
47
+ """No ~/.conductguard/config.json → no POST attempted."""
48
+ monkeypatch.setattr(log_util, "_CONFIG", tmp_path / "missing.json")
49
+ log_util._read_creds.cache_clear()
50
+
51
+ called = {"urlopen": 0}
52
+ def _fake_urlopen(*a, **k):
53
+ called["urlopen"] += 1
54
+ monkeypatch.setattr(log_util.urllib.request, "urlopen", _fake_urlopen)
55
+
56
+ log_util._post_event_sync("error", "boom", None, {})
57
+ assert called["urlopen"] == 0
58
+
59
+
60
+ def test_local_file_gets_jsonl_line(monkeypatch, tmp_path):
61
+ """log() writes a JSONL line for warn/error to the local file."""
62
+ import json
63
+ log_file = tmp_path / "events.jsonl"
64
+ monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
65
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
66
+
67
+ log_util.log("error", "boom-on-disk", run="r1")
68
+
69
+ assert log_file.exists()
70
+ lines = log_file.read_text().strip().splitlines()
71
+ assert len(lines) == 1
72
+ row = json.loads(lines[0])
73
+ assert row["event_type"] == "error"
74
+ assert row["message"] == "boom-on-disk"
75
+ assert row["tool"] == log_util.TOOL
76
+ assert row["context"] == {"run": "r1"}
77
+
78
+
79
+ def test_local_file_rotates_when_oversize(monkeypatch, tmp_path):
80
+ """When the file passes _MAX_BYTES, current file rotates to .1 and a fresh file starts."""
81
+ from pathlib import Path
82
+ log_file = tmp_path / "events.jsonl"
83
+ monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
84
+ monkeypatch.setattr(log_util, "_MAX_BYTES", 200) # tiny cap so the test rotates quickly
85
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
86
+
87
+ # First write — creates the file
88
+ log_util.log("error", "first")
89
+ assert log_file.exists()
90
+
91
+ # Fill the file past the cap so the next write triggers rotation
92
+ log_file.write_text("x" * 300)
93
+
94
+ log_util.log("error", "after-rotation")
95
+
96
+ rotated = Path(str(log_file) + ".1")
97
+ assert rotated.exists(), "rotation should produce events.jsonl.1"
98
+ assert log_file.exists() and log_file.stat().st_size > 0
99
+ # Newest event lives in the current file, not the rotated one
100
+ assert "after-rotation" in log_file.read_text()
101
+
102
+
103
+ def test_local_file_persists_traceback_on_interceptor(monkeypatch, tmp_path):
104
+ """Unhandled exception → traceback lands in the local file."""
105
+ import json
106
+ log_file = tmp_path / "events.jsonl"
107
+ monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
108
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
109
+
110
+ def _boom():
111
+ raise RuntimeError("disk-trace")
112
+
113
+ wrapped = log_util.install_error_interceptor(_boom)
114
+ with pytest.raises(SystemExit):
115
+ wrapped()
116
+
117
+ rows = [json.loads(l) for l in log_file.read_text().splitlines()]
118
+ assert any(
119
+ r["event_type"] == "error"
120
+ and "RuntimeError" in r["message"]
121
+ and r["traceback"] and "disk-trace" in r["traceback"]
122
+ for r in rows
123
+ )
File without changes
File without changes
File without changes