conduct-cli 0.6.33__tar.gz → 0.6.35__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 (38) hide show
  1. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/PKG-INFO +1 -1
  2. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/pyproject.toml +1 -1
  3. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/guard.py +148 -17
  4. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/base.py +6 -1
  5. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/pretooluse.py +12 -8
  6. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/main.py +5 -7
  7. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  8. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/README.md +0 -0
  9. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/setup.cfg +0 -0
  10. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/setup.py +0 -0
  11. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/__init__.py +0 -0
  12. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/api.py +0 -0
  13. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/guardmcp.py +0 -0
  14. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/__init__.py +0 -0
  15. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/posttooluse.py +0 -0
  16. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/precompact.py +0 -0
  17. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/session_parser.py +0 -0
  18. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/session_start.py +0 -0
  19. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/hooks/stop.py +0 -0
  20. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/log_util.py +0 -0
  21. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/mcp_server.py +0 -0
  22. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/memory.py +0 -0
  23. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli/paxel.py +0 -0
  24. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  25. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  26. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  27. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli.egg-info/requires.txt +0 -0
  28. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/src/conduct_cli.egg-info/top_level.txt +0 -0
  29. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_bash_operator_signature.py +0 -0
  30. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_command_word_matcher.py +0 -0
  31. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_guard_policy.py +0 -0
  32. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_guard_savings.py +0 -0
  33. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_hook_syntax.py +0 -0
  34. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_journal_drain.py +0 -0
  35. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_log_util.py +0 -0
  36. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_proxy_env.py +0 -0
  37. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/tests/test_signed_policy.py +0 -0
  38. {conduct_cli-0.6.33 → conduct_cli-0.6.35}/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.33
3
+ Version: 0.6.35
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.6.33"
7
+ version = "0.6.35"
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" }
@@ -21,6 +21,10 @@ GUARD_DIR = Path.home() / ".conductguard"
21
21
  CONFIG_PATH = GUARD_DIR / "config.json"
22
22
  POLICY_PATH = GUARD_DIR / "policy.json"
23
23
 
24
+
25
+ def active_policy_path() -> Path:
26
+ return POLICY_PATH
27
+
24
28
  # ── Hook templates — loaded from real .py files (no string embedding) ─────────
25
29
 
26
30
  # ── Thin launcher content ─────────────────────────────────────────────────────
@@ -102,11 +106,8 @@ def _bash_command_words(cmd: str) -> list[str]:
102
106
 
103
107
  def _check_policy(tool_name, tool_input, tokens_before=0):
104
108
  """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
105
- if not POLICY_PATH.exists():
106
- return None, "allow", None, None
107
- try:
108
- policy = _json.loads(POLICY_PATH.read_text())
109
- except Exception:
109
+ policy = _load_policy()
110
+ if not policy.get("rules"):
110
111
  return None, "allow", None, None
111
112
 
112
113
  rules = policy.get("rules", [])
@@ -305,13 +306,16 @@ def _ensure_persona(workspace_id: str, api_key: str, base_url: str) -> str:
305
306
  return chosen
306
307
 
307
308
 
308
- def _load_guard_config() -> dict:
309
+ def _load_guard_config(workspace_id: str | None = None) -> dict:
309
310
  if CONFIG_PATH.exists():
310
- return json.loads(CONFIG_PATH.read_text())
311
+ try:
312
+ return json.loads(CONFIG_PATH.read_text())
313
+ except Exception:
314
+ pass
311
315
  return {}
312
316
 
313
317
 
314
- def _save_guard_config(data: dict):
318
+ def _save_guard_config(data: dict, workspace_id: str | None = None):
315
319
  GUARD_DIR.mkdir(parents=True, exist_ok=True)
316
320
  CONFIG_PATH.write_text(json.dumps(data, indent=2))
317
321
  CONFIG_PATH.chmod(0o600)
@@ -572,12 +576,12 @@ def _req(method: str, url: str, body=None, token: str = None, api_key: str = Non
572
576
 
573
577
 
574
578
 
575
- def _save_policy(policy: dict):
579
+ def _save_policy(policy: dict, workspace_id: str | None = None):
576
580
  GUARD_DIR.mkdir(parents=True, exist_ok=True)
577
581
  POLICY_PATH.write_text(json.dumps(policy, indent=2))
578
582
 
579
583
 
580
- def _load_policy() -> dict:
584
+ def _load_policy(workspace_id: str | None = None) -> dict:
581
585
  try:
582
586
  return json.loads(POLICY_PATH.read_text())
583
587
  except Exception:
@@ -774,6 +778,26 @@ def cmd_guard_install(args):
774
778
  if signing_key_hex:
775
779
  _write_signing_key(signing_key_hex)
776
780
 
781
+ # Start watch daemon — auto-discovers agents every 15 min, no user action needed.
782
+ try:
783
+ import subprocess as _sp, sys as _sys
784
+ running, _ = _is_watch_running()
785
+ if not running:
786
+ log_file = open(_WATCH_LOG, "a")
787
+ proc = _sp.Popen(
788
+ [_sys.executable, "-c",
789
+ "from conduct_cli.guard import _watch_loop; _watch_loop()"],
790
+ start_new_session=True,
791
+ stdout=log_file,
792
+ stderr=log_file,
793
+ )
794
+ _WATCH_PID.write_text(str(proc.pid))
795
+ print(f" {GREEN}Guard watch:{RESET} background discovery daemon started (pid {proc.pid})")
796
+ else:
797
+ print(f" {GREEN}Guard watch:{RESET} already running")
798
+ except Exception:
799
+ pass # non-fatal
800
+
777
801
 
778
802
  def _write_signing_key(hex_key: str) -> None:
779
803
  """Write a hex-encoded signing key to ~/.conductguard/signing.key.
@@ -1634,13 +1658,7 @@ def cmd_guard_status(args):
1634
1658
  pass
1635
1659
 
1636
1660
  # Load local policy for rule count
1637
- rule_count = 0
1638
- if POLICY_PATH.exists():
1639
- try:
1640
- policy = json.loads(POLICY_PATH.read_text())
1641
- rule_count = len(policy.get("rules", []))
1642
- except Exception:
1643
- pass
1661
+ rule_count = len(_load_policy().get("rules", []))
1644
1662
 
1645
1663
  # Fetch today's spend
1646
1664
  spend = {}
@@ -1884,6 +1902,11 @@ def register_guard_parser(sub):
1884
1902
  discover_p.add_argument("--config-only", action="store_true", help="Skip process scan, config files only")
1885
1903
  discover_p.add_argument("--report", default=None, metavar="FILE", help="Write full JSON report to file")
1886
1904
 
1905
+ # conduct guard watch
1906
+ watch_p = guard_sub.add_parser("watch", help="Start background daemon — scans every 15 min, auto-pushes to Guard")
1907
+ watch_p.add_argument("--stop", action="store_true", help="Stop the running watch daemon")
1908
+ watch_p.add_argument("--status", action="store_true", help="Show whether the watch daemon is running")
1909
+
1887
1910
  # conduct guard lint [--file PATH]
1888
1911
  lint_p = guard_sub.add_parser("lint", help="Validate local policy — show errors and warnings")
1889
1912
  lint_p.add_argument("--file", default=None, metavar="FILE",
@@ -2011,6 +2034,112 @@ def _scan_processes() -> list[dict]:
2011
2034
  return found
2012
2035
 
2013
2036
 
2037
+ _WATCH_PID = Path.home() / ".conductguard" / "watch.pid"
2038
+ _WATCH_LOG = Path.home() / ".conductguard" / "watch.log"
2039
+ _WATCH_INTERVAL = 15 * 60 # seconds
2040
+
2041
+
2042
+ def _is_watch_running():
2043
+ """Returns (is_running: bool, pid: int | None)."""
2044
+ if not _WATCH_PID.exists():
2045
+ return False, None
2046
+ try:
2047
+ pid = int(_WATCH_PID.read_text().strip())
2048
+ os.kill(pid, 0)
2049
+ return True, pid
2050
+ except (ValueError, ProcessLookupError):
2051
+ _WATCH_PID.unlink(missing_ok=True)
2052
+ return False, None
2053
+
2054
+
2055
+ def _watch_loop():
2056
+ """Background loop — runs discover+push every 15 min. Invoked as subprocess."""
2057
+ import time, json as _json
2058
+ # resolve config at each iteration so workspace switches are picked up
2059
+ def _cfg():
2060
+ return _load_guard_config()
2061
+
2062
+ while True:
2063
+ try:
2064
+ c = _cfg()
2065
+ api_url = _api_url(c)
2066
+ token = c.get("member_token", "")
2067
+ api_key = c.get("api_key", "")
2068
+ agents = []
2069
+ for name, config_path, mcp_key in _discover_config_agents():
2070
+ agents.append({
2071
+ "name": name, "framework": name, "source": "config",
2072
+ "location": str(config_path),
2073
+ "evidence": {"config_path": str(config_path), "under_guard": mcp_key},
2074
+ "risk_score": 20 if mcp_key else 70,
2075
+ "under_guard": mcp_key,
2076
+ })
2077
+ try:
2078
+ process_agents = _scan_processes()
2079
+ registered = {a["framework"] for a in agents if a["under_guard"]}
2080
+ for a in process_agents:
2081
+ a["under_guard"] = a["framework"] in registered
2082
+ agents += process_agents
2083
+ except Exception:
2084
+ pass
2085
+ _req("POST", f"{api_url}/guard/discover/scan",
2086
+ body={"triggered_by": "watch", "agents": agents},
2087
+ token=token, api_key=api_key)
2088
+ except Exception:
2089
+ pass
2090
+ time.sleep(_WATCH_INTERVAL)
2091
+
2092
+
2093
+ def cmd_guard_watch(args):
2094
+ """Start/stop/status the background discovery daemon."""
2095
+ import signal as _signal
2096
+
2097
+ if getattr(args, "status", False):
2098
+ running, pid = _is_watch_running()
2099
+ if running:
2100
+ print(f" Guard watch daemon running (pid {pid})")
2101
+ log_tail = ""
2102
+ if _WATCH_LOG.exists():
2103
+ lines = _WATCH_LOG.read_text().splitlines()
2104
+ log_tail = "\n".join(lines[-5:])
2105
+ if log_tail:
2106
+ print(f" Last log:\n{log_tail}")
2107
+ else:
2108
+ print(" Guard watch daemon not running. Start with: conduct guard watch")
2109
+ return
2110
+
2111
+ if getattr(args, "stop", False):
2112
+ running, pid = _is_watch_running()
2113
+ if not running:
2114
+ print(" Guard watch daemon is not running.")
2115
+ return
2116
+ os.kill(pid, _signal.SIGTERM)
2117
+ _WATCH_PID.unlink(missing_ok=True)
2118
+ print(f" Guard watch daemon stopped (pid {pid}).")
2119
+ return
2120
+
2121
+ # Start
2122
+ running, pid = _is_watch_running()
2123
+ if running:
2124
+ print(f" Guard watch daemon already running (pid {pid}). Stop with: conduct guard watch --stop")
2125
+ return
2126
+
2127
+ _WATCH_PID.parent.mkdir(parents=True, exist_ok=True)
2128
+ log_file = open(_WATCH_LOG, "a")
2129
+ import subprocess, sys as _sys
2130
+ proc = subprocess.Popen(
2131
+ [_sys.executable, "-c",
2132
+ "from conduct_cli.guard import _watch_loop; _watch_loop()"],
2133
+ start_new_session=True,
2134
+ stdout=log_file,
2135
+ stderr=log_file,
2136
+ )
2137
+ _WATCH_PID.write_text(str(proc.pid))
2138
+ print(f" Guard watch daemon started (pid {proc.pid}).")
2139
+ print(f" Scans every 15 min — results visible at conductai.ai/guard/discovery")
2140
+ print(f" Stop with: conduct guard watch --stop")
2141
+
2142
+
2014
2143
  def cmd_guard_lint(args):
2015
2144
  """Validate the local policy file and report errors / warnings."""
2016
2145
  import json as _json, re as _re
@@ -2329,6 +2458,8 @@ def dispatch_guard(args, guard_p):
2329
2458
  cmd_guard_install(args)
2330
2459
  elif guard_command == "discover":
2331
2460
  cmd_guard_discover(args)
2461
+ elif guard_command == "watch":
2462
+ cmd_guard_watch(args)
2332
2463
  elif guard_command == "lint":
2333
2464
  cmd_guard_lint(args)
2334
2465
  elif guard_command == "booster-status":
@@ -43,7 +43,7 @@ class HookResult:
43
43
  # ── Config loading ────────────────────────────────────────────────────────────
44
44
 
45
45
  def load_config() -> dict:
46
- """Return ~/.conductguard/config.json as a dict; empty dict on any error."""
46
+ """Load Guard config from ~/.conductguard/config.json."""
47
47
  try:
48
48
  if CONFIG_PATH.exists():
49
49
  return json.loads(CONFIG_PATH.read_text())
@@ -52,6 +52,11 @@ def load_config() -> dict:
52
52
  return {}
53
53
 
54
54
 
55
+ def active_policy_path() -> Path:
56
+ """Return policy path: ~/.conductguard/policy.json."""
57
+ return POLICY_PATH
58
+
59
+
55
60
  # ── Repo / tool detection ─────────────────────────────────────────────────────
56
61
 
57
62
  def detect_repo() -> Optional[str]:
@@ -18,6 +18,7 @@ from conduct_cli.hooks.base import (
18
18
  JOURNAL_DIR,
19
19
  JOURNAL_PID_PATH,
20
20
  POLICY_PATH,
21
+ active_policy_path,
21
22
  SIGNING_KEY_PATH,
22
23
  VERSION_CACHE_PATH,
23
24
  VERSION_CACHE_TTL,
@@ -152,18 +153,20 @@ def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, ho
152
153
  def _maybe_sync_policy() -> None:
153
154
  """Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
154
155
  try:
155
- cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
156
+ cfg = load_config()
156
157
  workspace_id = cfg.get("workspace_id")
157
158
  api_key = cfg.get("api_key", "")
158
159
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
159
160
  if not workspace_id:
160
161
  return
161
162
 
163
+ pol_path = active_policy_path()
164
+
162
165
  if _daemon_alive():
163
166
  url = f"{_DAEMON_URL}/policy?workspace_id={workspace_id}"
164
167
  with urllib.request.urlopen(url, timeout=1) as resp:
165
168
  remote = json.loads(resp.read())
166
- POLICY_PATH.write_text(json.dumps(remote, indent=2))
169
+ pol_path.write_text(json.dumps(remote, indent=2))
167
170
  return
168
171
 
169
172
  if VERSION_CACHE_PATH.exists():
@@ -188,8 +191,8 @@ def _maybe_sync_policy() -> None:
188
191
  remote_version = remote.get("version", "")
189
192
  local_version = ""
190
193
  local_policy: dict = {}
191
- if POLICY_PATH.exists():
192
- local_policy = json.loads(POLICY_PATH.read_text())
194
+ if pol_path.exists():
195
+ local_policy = json.loads(pol_path.read_text())
193
196
  local_version = local_policy.get("version", "")
194
197
 
195
198
  old_mode = local_policy.get("fail_mode", "fail_open")
@@ -207,7 +210,7 @@ def _maybe_sync_policy() -> None:
207
210
  return
208
211
 
209
212
  if remote_version != local_version:
210
- POLICY_PATH.write_text(json.dumps(remote, indent=2))
213
+ pol_path.write_text(json.dumps(remote, indent=2))
211
214
  VERSION_CACHE_PATH.write_text(json.dumps({"ts": time.time(), "version": remote_version}))
212
215
  except Exception:
213
216
  pass
@@ -292,10 +295,11 @@ def _bash_operator_signature(command: str) -> str:
292
295
 
293
296
  def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
294
297
  """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
295
- if not POLICY_PATH.exists():
298
+ pol_path = active_policy_path()
299
+ if not pol_path.exists():
296
300
  return None, "allow", None, None
297
301
  try:
298
- policy = json.loads(POLICY_PATH.read_text())
302
+ policy = json.loads(pol_path.read_text())
299
303
  except Exception:
300
304
  return None, "allow", None, None
301
305
 
@@ -409,7 +413,7 @@ def main() -> None:
409
413
  _this_file = Path(__file__).resolve()
410
414
 
411
415
  # Fail-closed gate
412
- if _get_fail_mode() == "fail_closed" and not POLICY_PATH.exists():
416
+ if _get_fail_mode() == "fail_closed" and not active_policy_path().exists():
413
417
  tool_name = (data.get("tool_name") or "").lower()
414
418
  tool_input = data.get("tool_input") or {}
415
419
  session_id = data.get("session_id")
@@ -2736,19 +2736,17 @@ def cmd_test_guard(args):
2736
2736
  """Test each guard policy rule with a matching synthetic tool call."""
2737
2737
  import json as _json
2738
2738
  import re as _re
2739
- from conduct_cli.guard import CONFIG_PATH, POLICY_PATH
2739
+ from conduct_cli.guard import _load_guard_config, _load_policy, active_policy_path
2740
2740
 
2741
- try:
2742
- cfg = _json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
2743
- except Exception:
2744
- cfg = {}
2741
+ cfg = _load_guard_config()
2742
+ pol_path = active_policy_path() # workspace-scoped
2745
2743
 
2746
- if not POLICY_PATH.exists():
2744
+ if not pol_path.exists():
2747
2745
  print(f"{RED}No policy file found. Run: conduct guard sync{RESET}")
2748
2746
  sys.exit(1)
2749
2747
 
2750
2748
  try:
2751
- policy = _json.loads(POLICY_PATH.read_text())
2749
+ policy = _load_policy()
2752
2750
  except Exception as e:
2753
2751
  print(f"{RED}Could not load policy: {e}{RESET}")
2754
2752
  sys.exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.33
3
+ Version: 0.6.35
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
File without changes
File without changes
File without changes