conduct-cli 0.6.0__tar.gz → 0.6.2__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 (32) hide show
  1. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/PKG-INFO +1 -1
  2. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/pyproject.toml +1 -1
  3. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/guard.py +138 -0
  4. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_session_start_template.py +59 -0
  5. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_template.py +110 -0
  6. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  7. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/README.md +0 -0
  8. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/setup.cfg +0 -0
  9. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/setup.py +0 -0
  10. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/__init__.py +0 -0
  11. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/api.py +0 -0
  12. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/guardmcp.py +0 -0
  13. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_precompact_template.py +0 -0
  14. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/hook_stop_template.py +0 -0
  15. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/log_util.py +0 -0
  16. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/main.py +0 -0
  17. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/mcp_server.py +0 -0
  18. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/memory.py +0 -0
  19. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli/paxel.py +0 -0
  20. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  21. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  22. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  23. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/requires.txt +0 -0
  24. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/src/conduct_cli.egg-info/top_level.txt +0 -0
  25. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_bash_operator_signature.py +0 -0
  26. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_command_word_matcher.py +0 -0
  27. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_guard_policy.py +0 -0
  28. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_guard_savings.py +0 -0
  29. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_hook_syntax.py +0 -0
  30. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_log_util.py +0 -0
  31. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/tests/test_proxy_env.py +0 -0
  32. {conduct_cli-0.6.0 → conduct_cli-0.6.2}/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.0
3
+ Version: 0.6.2
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.0"
7
+ version = "0.6.2"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -643,6 +643,38 @@ def cmd_guard_install(args):
643
643
  except Exception:
644
644
  pass
645
645
 
646
+ # Write signing key if provided by the admin at onboarding time.
647
+ signing_key_hex = getattr(args, "signing_key", None)
648
+ if signing_key_hex:
649
+ _write_signing_key(signing_key_hex)
650
+
651
+
652
+ def _write_signing_key(hex_key: str) -> None:
653
+ """Write a hex-encoded signing key to ~/.conductguard/signing.key.
654
+
655
+ Validates that the path resolves within GUARD_DIR (no traversal) and
656
+ that the value is a valid 64-char hex string (32 bytes).
657
+ """
658
+ import re as _re
659
+ signing_key_path = GUARD_DIR / "signing.key"
660
+
661
+ # Path traversal guard.
662
+ try:
663
+ resolved = signing_key_path.resolve()
664
+ resolved.relative_to(GUARD_DIR.resolve())
665
+ except (ValueError, RuntimeError):
666
+ print(f" {RED}Signing key path rejected (traversal check failed).{RESET}")
667
+ return
668
+
669
+ # Must be exactly 64 hex characters (32 bytes).
670
+ if not _re.fullmatch(r"[0-9a-fA-F]{64}", hex_key.strip()):
671
+ print(f" {RED}Invalid signing key: expected 64 hex characters.{RESET}")
672
+ return
673
+
674
+ GUARD_DIR.mkdir(parents=True, exist_ok=True)
675
+ signing_key_path.write_text(hex_key.strip())
676
+ print(f" {GREEN}Signing key written:{RESET} {signing_key_path}")
677
+
646
678
 
647
679
  def cmd_guard_join(args):
648
680
  invite_code = args.invite_code
@@ -830,6 +862,64 @@ def _report_tools_to_server() -> None:
830
862
  pass # Never surface errors — this is background telemetry
831
863
 
832
864
 
865
+ def _check_and_upgrade_packages() -> None:
866
+ """Print installed versions and pip-upgrade if PyPI has a newer release."""
867
+ import importlib.metadata
868
+ import urllib.request
869
+
870
+ packages = ["conduct-cli", "agent-booster"]
871
+ installed: dict[str, str] = {}
872
+ for pkg in packages:
873
+ try:
874
+ installed[pkg] = importlib.metadata.version(pkg)
875
+ except importlib.metadata.PackageNotFoundError:
876
+ # agent-booster may live under a different Python interpreter
877
+ try:
878
+ import subprocess as _sp, shutil
879
+ for py in ["python3.11", "python3.12", "python3.10"]:
880
+ if shutil.which(py):
881
+ out = _sp.check_output(
882
+ [py, "-c", f"import importlib.metadata; print(importlib.metadata.version('{pkg}'))"],
883
+ stderr=_sp.DEVNULL, text=True,
884
+ ).strip()
885
+ if out:
886
+ installed[pkg] = out
887
+ break
888
+ else:
889
+ installed[pkg] = "unknown"
890
+ except Exception:
891
+ installed[pkg] = "unknown"
892
+
893
+ print(f" conduct-cli {installed['conduct-cli']} · agent-booster {installed['agent-booster']}")
894
+
895
+ stale = []
896
+ for pkg, current in installed.items():
897
+ if current == "unknown":
898
+ continue
899
+ try:
900
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{pkg}/json", timeout=4) as r:
901
+ latest = __import__("json").loads(r.read())["info"]["version"]
902
+ if latest != current:
903
+ stale.append((pkg, current, latest))
904
+ except Exception:
905
+ pass # offline or PyPI down — skip silently
906
+
907
+ if not stale:
908
+ return
909
+
910
+ print(f" {YELLOW}Updates available:{RESET} " + ", ".join(f"{p} {c} → {l}" for p, c, l in stale))
911
+ try:
912
+ import subprocess
913
+ pkgs = [p for p, _, _ in stale]
914
+ subprocess.run(
915
+ [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet"] + pkgs,
916
+ check=True,
917
+ )
918
+ print(f" {GREEN}Updated:{RESET} " + ", ".join(f"{p} → {l}" for p, _, l in stale))
919
+ except Exception as e:
920
+ print(f" {YELLOW}Auto-update failed:{RESET} {e} — run: pip install --upgrade {' '.join(p for p,_,_ in stale)}")
921
+
922
+
833
923
  def cmd_guard_sync(args):
834
924
  cfg = _require_guard_config()
835
925
  workspace_id = cfg.get("workspace_id")
@@ -841,6 +931,7 @@ def cmd_guard_sync(args):
841
931
 
842
932
  dry_run = getattr(args, "dry_run", False)
843
933
  print(f"{'[dry-run] ' if dry_run else ''}Syncing policy…")
934
+ _check_and_upgrade_packages()
844
935
 
845
936
  try:
846
937
  policy = _req(
@@ -891,6 +982,24 @@ def cmd_guard_sync(args):
891
982
  _save_policy(policy)
892
983
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
893
984
 
985
+ # Refresh member token from server (workspace API key → fresh guard-mt- token).
986
+ # Never rely on the locally-cached value — it may be stale or missing.
987
+ try:
988
+ installed = _req(
989
+ "GET",
990
+ f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
991
+ api_key=api_key,
992
+ )
993
+ fresh_token = installed.get("member_token") or ""
994
+ if fresh_token:
995
+ cfg["member_token"] = fresh_token
996
+ _save_guard_config(cfg)
997
+ else:
998
+ print(f" {YELLOW}Warning: server returned no member token — proxy env may be stale{RESET}")
999
+ except Exception as e:
1000
+ fresh_token = ""
1001
+ print(f" {YELLOW}Warning: could not refresh member token ({e}) — using cached value{RESET}")
1002
+
894
1003
  # Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
895
1004
  # routes through Conduct Guard. Customer-overridable via --proxy-url or
896
1005
  # CONDUCT_PROXY_URL env var; defaults to api.conductai.ai/proxy.
@@ -1499,6 +1608,23 @@ def cmd_guard_audit(args):
1499
1608
  decision = ev.get("decision", "—")
1500
1609
  rule = (ev.get("rule_id") or ev.get("rule_message") or "—")
1501
1610
 
1611
+ # policy_signature_invalid events get a distinct BLOCK prefix line.
1612
+ if rule == "policy_signature_invalid":
1613
+ hostname = ""
1614
+ try:
1615
+ import json as _j
1616
+ payload = _j.loads(ev.get("input_summary") or "{}")
1617
+ hostname = payload.get("hostname", ev.get("hostname") or "")
1618
+ except Exception:
1619
+ pass
1620
+ ws_slug = cfg.get("workspace_id", "")
1621
+ print(
1622
+ f" {RED}[BLOCK]{RESET} {GRAY}{ts}{RESET} "
1623
+ f"policy_signature_invalid "
1624
+ f"host={hostname} workspace={ws_slug}"
1625
+ )
1626
+ continue
1627
+
1502
1628
  dec_color = RED if decision == "blocked" else GREEN if decision == "allowed" else GRAY
1503
1629
  print(
1504
1630
  f" {GRAY}{ts:<{ts_w}}{RESET} "
@@ -1542,6 +1668,16 @@ def register_guard_parser(sub):
1542
1668
  help="Time window: 1h, 24h, 7d, 30d (default: 24h)",
1543
1669
  )
1544
1670
 
1671
+ # conduct guard install [--signing-key <hex>]
1672
+ install_p = guard_sub.add_parser("install", help="Install Guard hook and download policies")
1673
+ install_p.add_argument(
1674
+ "--signing-key",
1675
+ default=None,
1676
+ metavar="HEX",
1677
+ help="Hex-encoded 32-byte signing key from POST /workspaces/{id}/signing-key. "
1678
+ "Written to ~/.conductguard/signing.key.",
1679
+ )
1680
+
1545
1681
  # conduct guard booster-status
1546
1682
  guard_sub.add_parser("booster-status", help="Verify Agent Booster intercept is active for this project")
1547
1683
 
@@ -1655,6 +1791,8 @@ def dispatch_guard(args, guard_p):
1655
1791
  cmd_guard_savings(args)
1656
1792
  elif guard_command == "audit":
1657
1793
  cmd_guard_audit(args)
1794
+ elif guard_command == "install":
1795
+ cmd_guard_install(args)
1658
1796
  elif guard_command == "booster-status":
1659
1797
  cmd_guard_booster_status(args)
1660
1798
  else:
@@ -9,12 +9,71 @@ SNAPSHOT_PATH = Path.home() / ".conductguard" / "session_snapshot.json"
9
9
  MAX_AGE_HOURS = 2
10
10
 
11
11
 
12
+ CONDUCT_ENV_PATH = Path.home() / ".conduct" / "env"
13
+ GUARD_CONFIG_PATH = Path.home() / ".conductguard" / "config.json"
14
+
15
+
16
+ def _check_proxy_token() -> None:
17
+ """Warn + alert server if the proxy token is missing or malformed.
18
+
19
+ Reads ~/.conduct/env directly (not shell env) so this works regardless
20
+ of whether the user sourced their rc file in this session.
21
+ """
22
+ if not CONDUCT_ENV_PATH.exists():
23
+ return
24
+ env_text = CONDUCT_ENV_PATH.read_text()
25
+ # Check if proxy is configured and token is valid
26
+ has_base_url = 'ANTHROPIC_BASE_URL=' in env_text
27
+ has_valid_token = 'ANTHROPIC_API_KEY="guard-mt-' in env_text or "ANTHROPIC_API_KEY='guard-mt-" in env_text
28
+ if not has_base_url:
29
+ return # proxy not configured — nothing to check
30
+ if has_valid_token:
31
+ return # all good
32
+
33
+ # Token is missing or malformed — warn locally and ping the server
34
+ print("## ConductGuard: proxy token missing or malformed")
35
+ print("Run `conduct guard sync` to refresh your token before making LLM calls.\n")
36
+
37
+ # Alert the server so the admin can prompt the developer to re-sync
38
+ try:
39
+ cfg: dict = {}
40
+ if GUARD_CONFIG_PATH.exists():
41
+ cfg = json.loads(GUARD_CONFIG_PATH.read_text())
42
+ workspace_id = cfg.get("workspace_id", "")
43
+ clerk_user_id = cfg.get("clerk_user_id", "") or cfg.get("user_email", "")
44
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
45
+ member_token = cfg.get("member_token", "")
46
+ if workspace_id and member_token:
47
+ import urllib.request
48
+ payload = json.dumps({
49
+ "workspace_id": workspace_id,
50
+ "clerk_user_id": clerk_user_id,
51
+ "ai_tool": "claude_code",
52
+ "tool_call": "session_start",
53
+ "decision": "warned",
54
+ "rule_id": "proxy_token_missing",
55
+ "rule_message": "Developer proxy token missing or malformed — run conduct guard sync",
56
+ "input_summary": "session_start: proxy token not found in ~/.conduct/env",
57
+ }).encode()
58
+ req = urllib.request.Request(
59
+ f"{api_url}/guard/events",
60
+ data=payload,
61
+ headers={"Content-Type": "application/json"},
62
+ method="POST",
63
+ )
64
+ urllib.request.urlopen(req, timeout=3)
65
+ except Exception:
66
+ pass # best-effort — never block the session
67
+
68
+
12
69
  def main():
13
70
  try:
14
71
  sys.stdin.read()
15
72
  except Exception:
16
73
  pass
17
74
 
75
+ _check_proxy_token()
76
+
18
77
  if not SNAPSHOT_PATH.exists():
19
78
  sys.exit(0)
20
79
 
@@ -38,6 +38,7 @@ BUDGET_CACHE_TTL = 300 # 5 minutes
38
38
  VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
39
39
  VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
40
40
  WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
41
+ SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
41
42
 
42
43
 
43
44
  _DAEMON_URL = "http://127.0.0.1:7878"
@@ -51,6 +52,91 @@ def _daemon_alive() -> bool:
51
52
  return False
52
53
 
53
54
 
55
+ def _verify_policy_signature(policy_dict: dict) -> bool:
56
+ """Verify HMAC-SHA256 signature on a policy dict. Returns True on success.
57
+
58
+ If SIGNING_KEY_PATH does not exist (dev mode / workspace has no signing key),
59
+ always returns True for backwards compatibility.
60
+
61
+ Uses hmac.compare_digest to prevent timing attacks.
62
+ """
63
+ import hashlib as _hashlib
64
+ import hmac as _hmac
65
+
66
+ # Path traversal guard: ensure SIGNING_KEY_PATH resolves within GUARD_DIR.
67
+ try:
68
+ resolved = SIGNING_KEY_PATH.resolve()
69
+ resolved.relative_to(GUARD_DIR.resolve())
70
+ except (ValueError, RuntimeError):
71
+ return True # path outside guard dir -- skip verification
72
+
73
+ if not SIGNING_KEY_PATH.exists():
74
+ return True # no local key -- dev mode, backwards compat
75
+
76
+ try:
77
+ raw = SIGNING_KEY_PATH.read_text().strip()
78
+ key_bytes = bytes.fromhex(raw)
79
+ except Exception:
80
+ return True # unreadable or malformed key -- skip verification
81
+
82
+ expected_sig = policy_dict.get("signature")
83
+ if not expected_sig:
84
+ # Policy has no signature but we have a local key -- treat as invalid.
85
+ return False
86
+
87
+ # Reconstruct the canonical body the server signed.
88
+ body_dict = {k: v for k, v in policy_dict.items() if k not in ("signature", "signed_at")}
89
+ canonical = json.dumps(body_dict, sort_keys=True, separators=(",", ":"))
90
+ computed_sig = _hmac.new(key_bytes, canonical.encode(), _hashlib.sha256).hexdigest()
91
+
92
+ return _hmac.compare_digest(expected_sig, computed_sig)
93
+
94
+
95
+ def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, hostname):
96
+ """Fire-and-forget audit event for a failed signature check."""
97
+ try:
98
+ cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
99
+ except Exception:
100
+ return
101
+ workspace_id = cfg.get("workspace_id")
102
+ if not workspace_id:
103
+ return
104
+ import platform as _platform
105
+ _hostname = hostname or _platform.node()
106
+ payload = json.dumps({
107
+ "workspace_id": workspace_id,
108
+ "clerk_user_id": cfg.get("user_email"),
109
+ "user_email": cfg.get("user_email"),
110
+ "ai_tool": "hook",
111
+ "tool_call": "policy_signature_invalid",
112
+ "input_summary": json.dumps({
113
+ "expected_signature": expected_sig or "",
114
+ "computed_signature": computed_sig or "",
115
+ "policy_version": policy_version or "",
116
+ "hostname": _hostname,
117
+ })[:500],
118
+ "decision": "blocked",
119
+ "rule_id": "policy_signature_invalid",
120
+ "rule_message": "Policy signature verification failed",
121
+ "hostname": _hostname,
122
+ })
123
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
124
+ script = (
125
+ "import urllib.request\n"
126
+ "try:\n"
127
+ f" req = urllib.request.Request(\"{api_url}/guard/events\","
128
+ f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\"}}, method=\"POST\")\n"
129
+ " urllib.request.urlopen(req, timeout=5)\n"
130
+ "except: pass\n"
131
+ )
132
+ subprocess.Popen(
133
+ [sys.executable, "-c", script],
134
+ stdout=subprocess.DEVNULL,
135
+ stderr=subprocess.DEVNULL,
136
+ start_new_session=True,
137
+ )
138
+
139
+
54
140
  def _maybe_sync_policy():
55
141
  """Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
56
142
  try:
@@ -78,6 +164,19 @@ def _maybe_sync_policy():
78
164
  req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else {})
79
165
  with urllib.request.urlopen(req, timeout=2) as resp:
80
166
  remote = json.loads(resp.read())
167
+
168
+ # Verify signature before writing to disk. If verification fails, keep
169
+ # the existing cached policy and emit an audit event.
170
+ import platform as _platform
171
+ if not _verify_policy_signature(remote):
172
+ _post_signature_invalid_event(
173
+ expected_sig=remote.get("signature"),
174
+ computed_sig=None,
175
+ policy_version=remote.get("version"),
176
+ hostname=_platform.node(),
177
+ )
178
+ return # do not overwrite the cached policy with a tampered one
179
+
81
180
  remote_version = remote.get("version", "")
82
181
  local_version = ""
83
182
  if POLICY_PATH.exists():
@@ -176,6 +275,17 @@ except Exception:
176
275
  except Exception:
177
276
  return None, "allow", None, None
178
277
 
278
+ # Verify the cached policy's signature on every hook invocation.
279
+ if not _verify_policy_signature(policy):
280
+ import platform as _platform
281
+ _post_signature_invalid_event(
282
+ expected_sig=policy.get("signature"),
283
+ computed_sig=None,
284
+ policy_version=policy.get("version"),
285
+ hostname=_platform.node(),
286
+ )
287
+ return None, "allow", None, None
288
+
179
289
  rules = policy.get("rules", [])
180
290
  # For Bash, match against operator signature (argv[0] + subcommand + flags) — not raw JSON.
181
291
  # Prevents false positives where dangerous patterns appear inside quoted argument values
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
File without changes
File without changes
File without changes