conduct-cli 0.7.17__tar.gz → 0.7.20__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 (44) hide show
  1. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/guard.py +147 -64
  4. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/main.py +19 -51
  5. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  6. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_guard_policy.py +36 -0
  7. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/README.md +0 -0
  8. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/setup.cfg +0 -0
  9. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/setup.py +0 -0
  10. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/__init__.py +0 -0
  11. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/api.py +0 -0
  12. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/guardmcp.py +0 -0
  13. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/__init__.py +0 -0
  14. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/base.py +0 -0
  15. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/posttooluse.py +0 -0
  16. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/precompact.py +0 -0
  17. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/pretooluse.py +0 -0
  18. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/session_parser.py +0 -0
  19. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/session_report_push.py +0 -0
  20. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/session_start.py +0 -0
  21. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/hooks/stop.py +0 -0
  22. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/log_util.py +0 -0
  23. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/mcp_server.py +0 -0
  24. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/memory.py +0 -0
  25. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/paxel.py +0 -0
  26. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli/tool_groups.py +0 -0
  27. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  28. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  29. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  30. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli.egg-info/requires.txt +0 -0
  31. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/src/conduct_cli.egg-info/top_level.txt +0 -0
  32. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_advisory_and_hook.py +0 -0
  33. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_bash_operator_signature.py +0 -0
  34. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_blast_radius.py +0 -0
  35. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_command_word_matcher.py +0 -0
  36. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_drain_daemon_health.py +0 -0
  37. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_guard_savings.py +0 -0
  38. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_hook_dispatch.py +0 -0
  39. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_journal_drain.py +0 -0
  40. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_log_util.py +0 -0
  41. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_proxy_coverage.py +0 -0
  42. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_proxy_env.py +0 -0
  43. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_signed_policy.py +0 -0
  44. {conduct_cli-0.7.17 → conduct_cli-0.7.20}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.17
3
+ Version: 0.7.20
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.7.17"
7
+ version = "0.7.20"
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" }
@@ -20,8 +20,9 @@ GRAY = "\033[90m"
20
20
  CYAN = "\033[36m"
21
21
  YELLOW = "\033[33m"
22
22
 
23
- GUARD_DIR = Path.home() / ".conductguard"
24
- CONFIG_PATH = GUARD_DIR / "config.json"
23
+ CONDUCT_HOME = Path.home() / ".conduct"
24
+ GUARD_DIR = CONDUCT_HOME
25
+ CONFIG_PATH = CONDUCT_HOME / "config.json" # unified config — shared with main.py
25
26
  POLICY_PATH = GUARD_DIR / "policy.json"
26
27
 
27
28
 
@@ -116,7 +117,7 @@ def _bash_command_words(cmd: str) -> list[str]:
116
117
  return words
117
118
 
118
119
 
119
- def _check_policy(tool_name, tool_input, tokens_before=0):
120
+ def _check_policy(tool_name, tool_input, tokens_before=0, ai_tool=""):
120
121
  """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
121
122
  policy = _load_policy()
122
123
  if not policy.get("rules"):
@@ -136,6 +137,11 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
136
137
  if match_tool != "*":
137
138
  if tool_name not in expand_match_tool(match_tool):
138
139
  continue
140
+ match_ai = rule.get("match_ai_tool")
141
+ if match_ai:
142
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
143
+ if not any(s in ai_tool.lower() for s in surfaces):
144
+ continue
139
145
  # New: structural match against the actual binary being invoked.
140
146
  # Single string or list of strings; case-insensitive equality.
141
147
  cw = rule.get("match_command_word")
@@ -328,14 +334,14 @@ def _load_guard_config(workspace_id: str | None = None) -> dict:
328
334
 
329
335
 
330
336
  def _save_guard_config(data: dict, workspace_id: str | None = None):
331
- GUARD_DIR.mkdir(parents=True, exist_ok=True)
337
+ CONDUCT_HOME.mkdir(parents=True, exist_ok=True)
332
338
  CONFIG_PATH.write_text(json.dumps(data, indent=2))
333
339
  CONFIG_PATH.chmod(0o600)
334
340
 
335
341
 
336
342
  def _require_guard_config() -> dict:
337
343
  cfg = _load_guard_config()
338
- ws = cfg.get("workspace_id")
344
+ ws = cfg.get("workspace_id") or cfg.get("workspace")
339
345
  if not cfg or not ws:
340
346
  print(f"{RED}Guard not connected. Run: conduct login --api-key <key>{RESET}", file=sys.stderr)
341
347
  sys.exit(0)
@@ -374,11 +380,11 @@ _MCP_TARGETS = [
374
380
  ]
375
381
 
376
382
 
377
- def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
383
+ def _register_mcp(workspace_id: str, agent_token: str, api_url: str) -> None:
378
384
  """Write conductguard + agent-booster MCP entries into every AI tool config found.
379
385
 
380
386
  Credentials are NOT stored in the MCP config — the server reads them from
381
- ~/.conductguard/config.json at startup, which is written by guard sync.
387
+ ~/.conduct/config.json at startup, which is written by guard sync.
382
388
  """
383
389
  import shutil
384
390
  servers: dict[str, dict] = {
@@ -414,13 +420,13 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
414
420
 
415
421
  # Claude Desktop doesn't source shell env — patch apiBaseUrl directly in config
416
422
  # so all LLM calls route through the Guard proxy (PII blocking, spend limits, audit).
417
- _patch_claude_desktop_proxy(api_url, member_token)
423
+ _patch_claude_desktop_proxy(api_url, agent_token)
418
424
 
419
425
  # Cursor global rules — write Guard policies as user rules so they apply across all projects.
420
426
  _patch_cursor_global_rules()
421
427
 
422
428
 
423
- def _patch_claude_desktop_proxy(api_url: str, member_token: str) -> None:
429
+ def _patch_claude_desktop_proxy(api_url: str, agent_token: str) -> None:
424
430
  """Patch Claude Desktop config to route LLM calls through the Guard proxy.
425
431
 
426
432
  Claude Desktop reads apiBaseUrl from its config JSON — it does not source
@@ -731,18 +737,18 @@ def cmd_guard_install(args):
731
737
  print(f" {GRAY}Guard not installed for this workspace — skipping{RESET}")
732
738
  return
733
739
 
734
- member_token = result.get("member_token") or ""
740
+ agent_token = result.get("agent_token") or ""
735
741
  user_email = result.get("user_email") or ""
736
742
  clerk_user_id = result.get("clerk_user_id") or ""
737
743
 
738
744
  # Persona selection — prompt once, skip if already chosen
739
745
  _ensure_persona(workspace_id, api_key, server)
740
746
 
741
- # Persist guard config — include api_key so CLI commands can authenticate
747
+ # Persist unified config — single ~/.conduct/config.json
742
748
  import time as _time
743
749
  _save_guard_config({
744
750
  "workspace_id": workspace_id,
745
- "member_token": member_token,
751
+ "agent_token": agent_token,
746
752
  "user_email": user_email,
747
753
  "clerk_user_id": clerk_user_id,
748
754
  "api_key": api_key,
@@ -779,7 +785,12 @@ def cmd_guard_install(args):
779
785
  _install_codex_hook(hook_path)
780
786
 
781
787
  # Register MCP in all found AI tools — Cursor/Windsurf (advisory)
782
- _register_mcp(workspace_id, member_token or "", server)
788
+ _register_mcp(workspace_id, agent_token or "", server)
789
+
790
+ _cd = next((t for t in _detect_ai_tools() if t["name"] == "claude-desktop" and not t.get("proxy_routed")), None)
791
+ if _cd:
792
+ print(f" {CYAN}Claude Desktop detected — patching apiBaseUrl to route through Guard proxy{RESET}")
793
+ _patch_claude_desktop_proxy(server, agent_token or "")
783
794
 
784
795
  # Install session persistence hooks (PreCompact + SessionStart)
785
796
  try:
@@ -814,7 +825,7 @@ def cmd_guard_install(args):
814
825
 
815
826
 
816
827
  def _write_signing_key(hex_key: str) -> None:
817
- """Write a hex-encoded signing key to ~/.conductguard/signing.key.
828
+ """Write a hex-encoded signing key to ~/.conduct/signing.key.
818
829
 
819
830
  Validates that the path resolves within GUARD_DIR (no traversal) and
820
831
  that the value is a valid 64-char hex string (32 bytes).
@@ -859,7 +870,7 @@ def cmd_guard_join(args):
859
870
  result = _req("POST", f"{base_url}/guard/join", body=payload)
860
871
 
861
872
  workspace_id = result["workspace_id"]
862
- member_token = result.get("member_token", "")
873
+ agent_token_install = result.get("agent_token", "")
863
874
  policy = result.get("policy", {"workspace_id": workspace_id, "version": "1", "rules": []})
864
875
 
865
876
  # Download and persist policy
@@ -875,8 +886,8 @@ def cmd_guard_join(args):
875
886
  "api_url": base_url,
876
887
  "last_synced_at": _time.time(),
877
888
  }
878
- if member_token:
879
- cfg["member_token"] = member_token
889
+ if agent_token_install:
890
+ cfg["agent_token"] = agent_token_install
880
891
  _save_guard_config(cfg)
881
892
 
882
893
  # Write hook script
@@ -894,18 +905,19 @@ def cmd_guard_join(args):
894
905
  )
895
906
 
896
907
 
897
- def _report_tools_to_server() -> None:
898
- """Detect AI coding tools on this machine and POST coverage to Guard API. Silent on failure."""
899
- home = Path.home()
908
+ def _is_anthropic_proxied() -> bool:
909
+ url = os.environ.get("ANTHROPIC_BASE_URL", "")
910
+ return "conductai.ai" in url or "conduct" in url.lower()
900
911
 
901
- def _check_json_key(path: Path, *keys) -> bool:
902
- try:
903
- d = json.loads(path.read_text()) if path.exists() else {}
904
- for k in keys:
905
- d = d.get(k, {}) if isinstance(d, dict) else {}
906
- return bool(d) and isinstance(d, dict) and len(d) > 0
907
- except Exception:
908
- return False
912
+
913
+ def _is_openai_proxied() -> bool:
914
+ url = os.environ.get("OPENAI_BASE_URL", "")
915
+ return "conductai.ai" in url or "conduct" in url.lower()
916
+
917
+
918
+ def _detect_ai_tools() -> list[dict]:
919
+ """Return list of detected AI coding tools with mcp_registered, hook_registered, proxy_routed."""
920
+ home = Path.home()
909
921
 
910
922
  def _check_json_mcp(path: Path) -> bool:
911
923
  try:
@@ -929,6 +941,20 @@ def _report_tools_to_server() -> None:
929
941
  except Exception:
930
942
  return False
931
943
 
944
+ def _claude_desktop_proxied() -> bool:
945
+ candidates = [
946
+ home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
947
+ home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
948
+ ]
949
+ for p in candidates:
950
+ if p.exists():
951
+ try:
952
+ url = json.loads(p.read_text()).get("apiBaseUrl", "")
953
+ return "conductai.ai" in url or "conduct" in url.lower()
954
+ except Exception:
955
+ pass
956
+ return False
957
+
932
958
  tools = []
933
959
 
934
960
  claude_dir = home / ".claude"
@@ -938,16 +964,27 @@ def _report_tools_to_server() -> None:
938
964
  "name": "claude-code",
939
965
  "mcp_registered": _check_json_mcp(settings),
940
966
  "hook_registered": _check_json_hook(settings),
967
+ "proxy_routed": _is_anthropic_proxied(),
941
968
  })
942
969
 
943
970
  codex_dir = home / ".codex"
944
971
  if codex_dir.exists():
945
972
  config = codex_dir / "config.toml"
946
- tools.append({
947
- "name": "codex",
948
- "mcp_registered": _check_toml_str(config, "conduct-mcp"),
949
- "hook_registered": _check_toml_str(config, "conductguard"),
950
- })
973
+ is_desktop = _check_toml_str(config, "[desktop]")
974
+ if is_desktop:
975
+ tools.append({
976
+ "name": "codex-desktop",
977
+ "mcp_registered": _check_toml_str(config, "conduct-mcp"),
978
+ "hook_registered": _check_toml_str(config, "conductguard"),
979
+ "proxy_routed": False,
980
+ })
981
+ else:
982
+ tools.append({
983
+ "name": "codex",
984
+ "mcp_registered": _check_toml_str(config, "conduct-mcp"),
985
+ "hook_registered": _check_toml_str(config, "conductguard"),
986
+ "proxy_routed": _is_openai_proxied(),
987
+ })
951
988
 
952
989
  cursor_dir = home / ".cursor"
953
990
  if cursor_dir.exists():
@@ -955,6 +992,7 @@ def _report_tools_to_server() -> None:
955
992
  "name": "cursor",
956
993
  "mcp_registered": _check_json_mcp(cursor_dir / "mcp.json"),
957
994
  "hook_registered": False,
995
+ "proxy_routed": _is_anthropic_proxied(),
958
996
  })
959
997
 
960
998
  windsurf_dir = home / ".codeium" / "windsurf"
@@ -963,6 +1001,19 @@ def _report_tools_to_server() -> None:
963
1001
  "name": "windsurf",
964
1002
  "mcp_registered": _check_json_mcp(windsurf_dir / "mcp_config.json"),
965
1003
  "hook_registered": False,
1004
+ "proxy_routed": _is_anthropic_proxied(),
1005
+ })
1006
+
1007
+ claude_desktop_candidates = [
1008
+ home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
1009
+ home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
1010
+ ]
1011
+ if any(p.exists() for p in claude_desktop_candidates):
1012
+ tools.append({
1013
+ "name": "claude-desktop",
1014
+ "mcp_registered": False,
1015
+ "hook_registered": False,
1016
+ "proxy_routed": _claude_desktop_proxied(),
966
1017
  })
967
1018
 
968
1019
  vscode_ext_dir = home / ".vscode" / "extensions"
@@ -985,8 +1036,16 @@ def _report_tools_to_server() -> None:
985
1036
  "name": "vscode",
986
1037
  "mcp_registered": mcp_reg,
987
1038
  "hook_registered": False,
1039
+ "proxy_routed": False,
988
1040
  })
989
1041
 
1042
+ return tools
1043
+
1044
+
1045
+ def _report_tools_to_server() -> None:
1046
+ """Detect AI coding tools on this machine and POST coverage to Guard API. Silent on failure."""
1047
+ tools = _detect_ai_tools()
1048
+
990
1049
  if not tools:
991
1050
  return
992
1051
 
@@ -994,7 +1053,7 @@ def _report_tools_to_server() -> None:
994
1053
  cfg = _load_guard_config()
995
1054
  base_url = _api_url(cfg)
996
1055
  email = cfg.get("user_email", "")
997
- token = cfg.get("member_token", "")
1056
+ token = cfg.get("agent_token", "")
998
1057
  api_key = cfg.get("api_key", "")
999
1058
 
1000
1059
  if not email:
@@ -1097,7 +1156,7 @@ def _check_and_upgrade_packages() -> None:
1097
1156
 
1098
1157
  def cmd_guard_sync(args):
1099
1158
  cfg = _require_guard_config()
1100
- workspace_id = cfg.get("workspace_id")
1159
+ workspace_id = cfg.get("workspace_id") or cfg.get("workspace")
1101
1160
  api_key = cfg.get("api_key", "")
1102
1161
  base_url = _api_url(cfg)
1103
1162
 
@@ -1165,15 +1224,14 @@ def cmd_guard_sync(args):
1165
1224
  f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
1166
1225
  api_key=api_key,
1167
1226
  )
1168
- fresh_token = installed.get("member_token") or ""
1169
- if fresh_token:
1170
- cfg["member_token"] = fresh_token
1227
+ fresh_agent_token = installed.get("agent_token") or ""
1228
+ if fresh_agent_token:
1229
+ cfg["agent_token"] = fresh_agent_token
1171
1230
  _save_guard_config(cfg)
1172
1231
  else:
1173
- print(f" {YELLOW}Warning: server returned no member token — proxy env may be stale{RESET}")
1232
+ print(f" {YELLOW}Warning: server returned no token — proxy env may be stale{RESET}")
1174
1233
  except Exception as e:
1175
- fresh_token = ""
1176
- print(f" {YELLOW}Warning: could not refresh member token ({e}) — using cached value{RESET}")
1234
+ print(f" {YELLOW}Warning: could not refresh token ({e}) — using cached value{RESET}")
1177
1235
 
1178
1236
  # Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
1179
1237
  # routes through Conduct Guard. Customer-overridable via --proxy-url or
@@ -1183,7 +1241,7 @@ def cmd_guard_sync(args):
1183
1241
  try:
1184
1242
  import urllib.request as _ur, urllib.error as _ue
1185
1243
  _headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
1186
- _token = cfg.get("member_token", "")
1244
+ _token = cfg.get("agent_token", "")
1187
1245
  if _token:
1188
1246
  _headers["Authorization"] = f"Bearer {_token}"
1189
1247
  _r = _ur.Request(f"{base_url}/guard/proxy-config", headers=_headers)
@@ -1191,13 +1249,29 @@ def cmd_guard_sync(args):
1191
1249
  proxy_url = json.loads(_resp.read()).get("conduct_proxy_url") or DEFAULT_PROXY_URL
1192
1250
  except Exception:
1193
1251
  proxy_url = DEFAULT_PROXY_URL
1194
- member_token = cfg.get("member_token", "")
1195
- rc_path, newly_sourced = _write_proxy_env(member_token, proxy_url)
1196
- if member_token:
1252
+ agent_token = cfg.get("agent_token", "")
1253
+ rc_path, newly_sourced = _write_proxy_env(agent_token, proxy_url)
1254
+ if agent_token:
1197
1255
  print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/env → {proxy_url}")
1198
1256
  if newly_sourced:
1199
1257
  print(f" {CYAN}Run `source {rc_path}` (or open a new shell) to activate.{RESET}")
1200
1258
 
1259
+ _tools = _detect_ai_tools()
1260
+ if _tools:
1261
+ print(f"\n {'Tool':<20} {'Proxy Routed':<16} {'MCP':<8} {'Hooks'}")
1262
+ print(f" {'─'*20} {'─'*16} {'─'*8} {'─'*8}")
1263
+ for t in _tools:
1264
+ routed = "✓ Routed" if t.get("proxy_routed") else ("— Partial" if t["name"] == "codex-desktop" else "✗ Not routed")
1265
+ mcp = "✓" if t.get("mcp_registered") else "✗"
1266
+ hook = "✓" if t.get("hook_registered") else "—"
1267
+ print(f" {t['name']:<20} {routed:<16} {mcp:<8} {hook}")
1268
+ print()
1269
+
1270
+ _cd = next((t for t in _tools if t["name"] == "claude-desktop" and not t.get("proxy_routed")), None)
1271
+ if _cd:
1272
+ print(f" {CYAN}Claude Desktop detected — patching apiBaseUrl to route through Guard proxy{RESET}")
1273
+ _patch_claude_desktop_proxy(cfg.get("api_url", "https://api.conductai.ai"), agent_token)
1274
+
1201
1275
  # Local key audit — scan known config files for real provider keys that
1202
1276
  # may have been pasted before the dev onboarded onto Conduct. Findings
1203
1277
  # surface in /guard/audit with a 'LOCAL RISK' badge. --no-local-audit
@@ -1222,7 +1296,7 @@ def cmd_guard_sync(args):
1222
1296
  _install_claude_hook(hook_path)
1223
1297
  _install_codex_hook(hook_path)
1224
1298
  cfg2 = _load_guard_config()
1225
- _register_mcp(workspace_id, cfg2.get("member_token", ""), base_url)
1299
+ _register_mcp(workspace_id, cfg2.get("agent_token", ""), base_url)
1226
1300
  try:
1227
1301
  _install_session_hooks()
1228
1302
  except Exception:
@@ -1256,13 +1330,13 @@ def cmd_guard_sync(args):
1256
1330
  print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
1257
1331
 
1258
1332
  # Print Claude.ai remote MCP URL — requires one-time browser paste
1259
- member_token = cfg.get("member_token", "")
1260
- if workspace_id and member_token:
1333
+ agent_token = cfg.get("agent_token", "")
1334
+ if workspace_id and agent_token:
1261
1335
  mcp_url = "https://api.conductai.ai/guard/mcp"
1262
1336
  print(f"\n{BOLD}Claude.ai{RESET} (one-time browser setup):")
1263
1337
  print(f" Settings → MCP Servers → Add custom server")
1264
1338
  print(f" URL: {CYAN}{mcp_url}{RESET}")
1265
- print(f" Auth: {CYAN}Bearer {member_token}{RESET}\n")
1339
+ print(f" Auth: {CYAN}Bearer {agent_token}{RESET}\n")
1266
1340
 
1267
1341
 
1268
1342
  CONDUCT_DIR = Path.home() / ".conduct"
@@ -1351,13 +1425,13 @@ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None
1351
1425
  "findings": findings,
1352
1426
  },
1353
1427
  api_key=cfg.get("api_key", ""),
1354
- token=cfg.get("member_token", ""),
1428
+ token=cfg.get("agent_token", ""),
1355
1429
  )
1356
1430
  except Exception as e:
1357
1431
  print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
1358
1432
 
1359
1433
 
1360
- def _write_proxy_env(member_token: str, proxy_url: str) -> tuple[Path, bool]:
1434
+ def _write_proxy_env(agent_token: str, proxy_url: str) -> tuple[Path, bool]:
1361
1435
  """Write ~/.conduct/env with the 3 provider env-var pairs and ensure the
1362
1436
  user's shell rc sources it.
1363
1437
 
@@ -1366,12 +1440,12 @@ def _write_proxy_env(member_token: str, proxy_url: str) -> tuple[Path, bool]:
1366
1440
  User-managed customizations go in ~/.conduct/env-override which is sourced
1367
1441
  after the managed file.
1368
1442
  """
1369
- if not member_token:
1370
- print(f" {YELLOW}Proxy env skipped — no member token in config{RESET}")
1443
+ if not agent_token:
1444
+ print(f" {YELLOW}Proxy env skipped — no agent token in config{RESET}")
1371
1445
  return Path(), False
1372
1446
 
1373
1447
  CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
1374
- token = f"guard-mt-{member_token}"
1448
+ token = agent_token # cond_agt_* used directly as the API key value
1375
1449
  proxy = proxy_url.rstrip("/")
1376
1450
 
1377
1451
  PROXY_ENV_FILE.write_text("\n".join([
@@ -1616,10 +1690,10 @@ def _report_savings(cfg: dict, base_url: str, api_key: str) -> None:
1616
1690
  }
1617
1691
 
1618
1692
  try:
1619
- member_token = cfg.get("member_token", "")
1693
+ agent_token_evt = cfg.get("agent_token", "")
1620
1694
  headers = {"Content-Type": "application/json"}
1621
- if member_token:
1622
- headers["Authorization"] = f"Bearer {member_token}"
1695
+ if agent_token_evt:
1696
+ headers["Authorization"] = f"Bearer {agent_token_evt}"
1623
1697
  elif api_key:
1624
1698
  headers["X-Api-Key"] = api_key
1625
1699
  data = json.dumps(payload).encode()
@@ -1934,7 +2008,7 @@ def register_guard_parser(sub):
1934
2008
  default=None,
1935
2009
  metavar="HEX",
1936
2010
  help="Hex-encoded 32-byte signing key from POST /workspaces/{id}/signing-key. "
1937
- "Written to ~/.conductguard/signing.key.",
2011
+ "Written to ~/.conduct/signing.key.",
1938
2012
  )
1939
2013
 
1940
2014
  # conduct guard booster-status
@@ -1967,7 +2041,7 @@ def register_guard_parser(sub):
1967
2041
  # conduct guard lint [--file PATH]
1968
2042
  lint_p = guard_sub.add_parser("lint", help="Validate local policy — show errors and warnings")
1969
2043
  lint_p.add_argument("--file", default=None, metavar="FILE",
1970
- help="Path to policy YAML/JSON (default: ~/.conductguard/policy.json)")
2044
+ help="Path to policy YAML/JSON (default: ~/.conduct/policy.json)")
1971
2045
 
1972
2046
  return guard_p, guard_sub
1973
2047
 
@@ -2103,8 +2177,8 @@ def _scan_processes() -> list[dict]:
2103
2177
  return found
2104
2178
 
2105
2179
 
2106
- _WATCH_PID = Path.home() / ".conductguard" / "watch.pid"
2107
- _WATCH_LOG = Path.home() / ".conductguard" / "watch.log"
2180
+ _WATCH_PID = Path.home() / ".conduct" / "watch.pid"
2181
+ _WATCH_LOG = Path.home() / ".conduct" / "watch.log"
2108
2182
  _WATCH_INTERVAL = 15 * 60 # seconds
2109
2183
 
2110
2184
 
@@ -2132,7 +2206,7 @@ def _watch_loop():
2132
2206
  try:
2133
2207
  c = _cfg()
2134
2208
  api_url = _api_url(c)
2135
- token = c.get("member_token", "")
2209
+ token = c.get("agent_token", "")
2136
2210
  api_key = c.get("api_key", "")
2137
2211
  agents = []
2138
2212
  for name, config_path, mcp_key in _discover_config_agents():
@@ -2308,7 +2382,7 @@ def cmd_guard_discover(args):
2308
2382
 
2309
2383
  cfg = _load_guard_config()
2310
2384
  api_url = _api_url(cfg)
2311
- token = cfg.get("member_token", "")
2385
+ token = cfg.get("agent_token", "")
2312
2386
  api_key = cfg.get("api_key", "")
2313
2387
  hdrs = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
2314
2388
 
@@ -2359,7 +2433,16 @@ def cmd_guard_discover(args):
2359
2433
 
2360
2434
  # POST to API
2361
2435
  try:
2362
- payload = {"triggered_by": "cli", "agents": all_agents}
2436
+ agents_payload = list(all_agents)
2437
+ for t in _detect_ai_tools():
2438
+ agents_payload.append({
2439
+ "name": t["name"],
2440
+ "framework": t["name"],
2441
+ "source": "ai-tool",
2442
+ "under_guard": t.get("mcp_registered", False),
2443
+ "proxy_routed": t.get("proxy_routed", False),
2444
+ })
2445
+ payload = {"triggered_by": "cli", "agents": agents_payload}
2363
2446
  _req("POST", f"{api_url}/guard/discover/scan", body=payload, token=token, api_key=api_key)
2364
2447
  except SystemExit:
2365
2448
  pass # 401/network errors — local output still useful
@@ -339,14 +339,6 @@ def _report_tool_coverage() -> None:
339
339
  token = cfg.get("token", "")
340
340
  email = cfg.get("email", "")
341
341
 
342
- # also check guard config for email/token
343
- guard_cfg_path = Path.home() / ".conductguard" / "config.json"
344
- if guard_cfg_path.exists():
345
- gcfg = json.loads(guard_cfg_path.read_text())
346
- if not email:
347
- email = gcfg.get("user_email", "")
348
- if not token:
349
- token = gcfg.get("member_token", "")
350
342
 
351
343
  if not server or not email:
352
344
  return
@@ -1327,15 +1319,6 @@ def cmd_switch(args):
1327
1319
  cfg["workspace"] = new_id
1328
1320
  _atomic_write(CONFIG_PATH, cfg)
1329
1321
 
1330
- # Update ~/.conductguard/config.json atomically if it exists
1331
- guard_cfg_path = Path.home() / ".conductguard" / "config.json"
1332
- if guard_cfg_path.exists():
1333
- try:
1334
- guard_cfg = json.loads(guard_cfg_path.read_text())
1335
- guard_cfg["workspace_id"] = new_id
1336
- _atomic_write(guard_cfg_path, guard_cfg)
1337
- except Exception:
1338
- pass
1339
1322
 
1340
1323
  # Re-sync Guard policies for the new workspace
1341
1324
  try:
@@ -1382,26 +1365,21 @@ def cmd_whoami(args):
1382
1365
  print(f"{BOLD}Server:{RESET} {server}")
1383
1366
  print(f"{BOLD}API key:{RESET} {api_key_display}")
1384
1367
 
1385
- # Guard section
1386
- guard_cfg_path = Path.home() / ".conductguard" / "config.json"
1387
- policy_path = Path.home() / ".conductguard" / "policy.json"
1388
- hook_path = Path.home() / ".conductguard" / "hook.py"
1389
-
1390
- if guard_cfg_path.exists():
1391
- try:
1392
- gcfg = json.loads(guard_cfg_path.read_text())
1393
- user_email = gcfg.get("user_email", "")
1394
- rule_count = 0
1395
- if policy_path.exists():
1396
- try:
1397
- rule_count = len(json.loads(policy_path.read_text()).get("rules", []))
1398
- except Exception:
1399
- pass
1400
- hook_status = "hook installed" if hook_path.exists() else "hook missing"
1401
- email_part = f" | member: {user_email}" if user_email else ""
1402
- print(f"{BOLD}Guard:{RESET} {GREEN}✓ {hook_status}{RESET} | policy: {rule_count} rules{email_part}")
1403
- except Exception:
1404
- print(f"{BOLD}Guard:{RESET} {YELLOW}config unreadable{RESET}")
1368
+ # Guard section — all under ~/.conduct/
1369
+ policy_path = Path.home() / ".conduct" / "policy.json"
1370
+ hook_path = Path.home() / ".conduct" / "hook.py"
1371
+ guard_email = cfg.get("user_email", "")
1372
+ agent_token = cfg.get("agent_token", "")
1373
+ if agent_token or hook_path.exists():
1374
+ rule_count = 0
1375
+ if policy_path.exists():
1376
+ try:
1377
+ rule_count = len(json.loads(policy_path.read_text()).get("rules", []))
1378
+ except Exception:
1379
+ pass
1380
+ hook_status = "hook installed" if hook_path.exists() else "hook missing"
1381
+ email_part = f" | member: {guard_email}" if guard_email else ""
1382
+ print(f"{BOLD}Guard:{RESET} {GREEN}✓ {hook_status}{RESET} | policy: {rule_count} rules{email_part}")
1405
1383
  else:
1406
1384
  print(f"{BOLD}Guard:{RESET} not configured")
1407
1385
 
@@ -1558,8 +1536,7 @@ def _load_codex_sessions(active_cwds: set[str]) -> list[dict]:
1558
1536
  if not _CODEX_SESSIONS.exists():
1559
1537
  return []
1560
1538
 
1561
- guard_cfg = Path.home() / ".conductguard" / "config.json"
1562
- guard_on = guard_cfg.exists()
1539
+ guard_on = (Path.home() / ".conduct" / "config.json").exists()
1563
1540
 
1564
1541
  rows = []
1565
1542
  # Walk the last 2 days of session dirs
@@ -1616,8 +1593,7 @@ def _load_sessions() -> list[dict]:
1616
1593
  if not _CLAUDE_SESSIONS.exists():
1617
1594
  return []
1618
1595
 
1619
- guard_cfg = Path.home() / ".conductguard" / "config.json"
1620
- guard_on = guard_cfg.exists()
1596
+ guard_on = (Path.home() / ".conduct" / "config.json").exists()
1621
1597
 
1622
1598
  rows = []
1623
1599
  seen_sessions: set[str] = set()
@@ -1764,14 +1740,6 @@ def _render_tui(rows: list[dict]) -> None:
1764
1740
  """Full-screen TUI with live refresh. Uses rich.live if available, else ANSI loop."""
1765
1741
 
1766
1742
  cfg = _load_config()
1767
- guard_cfg = {}
1768
- _gp = Path.home() / ".conductguard" / "config.json"
1769
- if _gp.exists():
1770
- try:
1771
- guard_cfg = json.loads(_gp.read_text())
1772
- except Exception:
1773
- pass
1774
-
1775
1743
  def _build_rich_display(rows):
1776
1744
  from rich.table import Table
1777
1745
  from rich.panel import Panel
@@ -2849,8 +2817,8 @@ def cmd_memory(args):
2849
2817
 
2850
2818
  # ── Entry point ───────────────────────────────────────────────────────────────
2851
2819
 
2852
- _GUARD_CONFIG = Path.home() / ".conductguard" / "config.json"
2853
- _GUARD_SKIP = Path.home() / ".conductguard" / ".setup_skip"
2820
+ _GUARD_CONFIG = Path.home() / ".conduct" / "config.json"
2821
+ _GUARD_SKIP = Path.home() / ".conduct" / ".setup_skip"
2854
2822
 
2855
2823
  GREEN = "\033[32m"
2856
2824
  YELLOW = "\033[33m"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.17
3
+ Version: 0.7.20
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
@@ -126,6 +126,42 @@ class TestMatchTool:
126
126
  assert action == "warn"
127
127
 
128
128
 
129
+ # ── match_ai_tool (surface filtering) ────────────────────────────────────────
130
+
131
+ def _surface_rule(**kw):
132
+ return _rule(match_tool="shell", match_pattern=r".*", **kw)
133
+
134
+ class TestMatchAiTool:
135
+ def test_surface_match_blocks(self, policy_path):
136
+ policy_path([_surface_rule(match_ai_tool="claude-ai", action="block")])
137
+ _, action, _, _ = _check_policy("bash", {"command": "ls"}, ai_tool="claude-ai")
138
+ assert action == "block"
139
+
140
+ def test_surface_no_match_allows(self, policy_path):
141
+ # rule targets claude-ai, tool is claude-code — should not fire
142
+ policy_path([_surface_rule(match_ai_tool="claude-ai", action="block")])
143
+ _, action, _, _ = _check_policy("bash", {"command": "ls"}, ai_tool="claude-code")
144
+ assert action == "allow"
145
+
146
+ def test_surface_multi_targets(self, policy_path):
147
+ # comma-separated: matches any listed surface
148
+ policy_path([_surface_rule(match_ai_tool="claude-ai,openai-chatgpt", action="warn")])
149
+ _, action, _, _ = _check_policy("bash", {"command": "ls"}, ai_tool="openai-chatgpt")
150
+ assert action == "warn"
151
+
152
+ def test_no_match_ai_tool_field_always_fires(self, policy_path):
153
+ # rule with no match_ai_tool applies to all surfaces
154
+ policy_path([_surface_rule(action="warn")])
155
+ _, action, _, _ = _check_policy("bash", {"command": "ls"}, ai_tool="cursor")
156
+ assert action == "warn"
157
+
158
+ def test_unknown_surface_skipped(self, policy_path):
159
+ # empty ai_tool string doesn't match a specific surface rule
160
+ policy_path([_surface_rule(match_ai_tool="claude-ai", action="block")])
161
+ _, action, _, _ = _check_policy("bash", {"command": "ls"}, ai_tool="")
162
+ assert action == "allow"
163
+
164
+
129
165
  # ── match_pattern ─────────────────────────────────────────────────────────────
130
166
 
131
167
  class TestMatchPattern:
File without changes
File without changes
File without changes