conduct-cli 0.7.14__tar.gz → 0.7.19__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.14 → conduct_cli-0.7.19}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/guard.py +148 -63
  4. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/guardmcp.py +21 -7
  5. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/base.py +19 -5
  6. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/pretooluse.py +8 -1
  7. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/main.py +19 -51
  8. conduct_cli-0.7.19/src/conduct_cli/tool_groups.py +58 -0
  9. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  10. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  11. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_advisory_and_hook.py +62 -0
  12. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_guard_policy.py +44 -4
  13. conduct_cli-0.7.19/tests/test_proxy_coverage.py +79 -0
  14. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/README.md +0 -0
  15. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/setup.cfg +0 -0
  16. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/setup.py +0 -0
  17. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/__init__.py +0 -0
  18. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/api.py +0 -0
  19. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/__init__.py +0 -0
  20. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/posttooluse.py +0 -0
  21. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/precompact.py +0 -0
  22. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/session_parser.py +0 -0
  23. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/session_report_push.py +0 -0
  24. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/session_start.py +0 -0
  25. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/stop.py +0 -0
  26. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/log_util.py +0 -0
  27. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/mcp_server.py +0 -0
  28. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/memory.py +0 -0
  29. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/paxel.py +0 -0
  30. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  31. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  32. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/requires.txt +0 -0
  33. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/top_level.txt +0 -0
  34. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_bash_operator_signature.py +0 -0
  35. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_blast_radius.py +0 -0
  36. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_command_word_matcher.py +0 -0
  37. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_drain_daemon_health.py +0 -0
  38. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_guard_savings.py +0 -0
  39. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_hook_dispatch.py +0 -0
  40. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_journal_drain.py +0 -0
  41. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_log_util.py +0 -0
  42. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_proxy_env.py +0 -0
  43. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_signed_policy.py +0 -0
  44. {conduct_cli-0.7.14 → conduct_cli-0.7.19}/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.14
3
+ Version: 0.7.19
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.14"
7
+ version = "0.7.19"
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" }
@@ -9,6 +9,8 @@ import urllib.request
9
9
  from datetime import datetime, timedelta, timezone
10
10
  from pathlib import Path
11
11
 
12
+ from conduct_cli.tool_groups import expand_match_tool
13
+
12
14
  RESET = "\033[0m"
13
15
  BOLD = "\033[1m"
14
16
  GREEN = "\033[32m"
@@ -18,8 +20,9 @@ GRAY = "\033[90m"
18
20
  CYAN = "\033[36m"
19
21
  YELLOW = "\033[33m"
20
22
 
21
- GUARD_DIR = Path.home() / ".conductguard"
22
- 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
23
26
  POLICY_PATH = GUARD_DIR / "policy.json"
24
27
 
25
28
 
@@ -114,7 +117,7 @@ def _bash_command_words(cmd: str) -> list[str]:
114
117
  return words
115
118
 
116
119
 
117
- def _check_policy(tool_name, tool_input, tokens_before=0):
120
+ def _check_policy(tool_name, tool_input, tokens_before=0, ai_tool=""):
118
121
  """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
119
122
  policy = _load_policy()
120
123
  if not policy.get("rules"):
@@ -132,7 +135,12 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
132
135
  for rule in rules:
133
136
  match_tool = (rule.get("match_tool") or "*").lower()
134
137
  if match_tool != "*":
135
- if tool_name not in [t.strip() for t in match_tool.split(",")]:
138
+ if tool_name not in expand_match_tool(match_tool):
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):
136
144
  continue
137
145
  # New: structural match against the actual binary being invoked.
138
146
  # Single string or list of strings; case-insensitive equality.
@@ -326,7 +334,7 @@ def _load_guard_config(workspace_id: str | None = None) -> dict:
326
334
 
327
335
 
328
336
  def _save_guard_config(data: dict, workspace_id: str | None = None):
329
- GUARD_DIR.mkdir(parents=True, exist_ok=True)
337
+ CONDUCT_HOME.mkdir(parents=True, exist_ok=True)
330
338
  CONFIG_PATH.write_text(json.dumps(data, indent=2))
331
339
  CONFIG_PATH.chmod(0o600)
332
340
 
@@ -372,11 +380,11 @@ _MCP_TARGETS = [
372
380
  ]
373
381
 
374
382
 
375
- 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:
376
384
  """Write conductguard + agent-booster MCP entries into every AI tool config found.
377
385
 
378
386
  Credentials are NOT stored in the MCP config — the server reads them from
379
- ~/.conductguard/config.json at startup, which is written by guard sync.
387
+ ~/.conduct/config.json at startup, which is written by guard sync.
380
388
  """
381
389
  import shutil
382
390
  servers: dict[str, dict] = {
@@ -412,13 +420,13 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
412
420
 
413
421
  # Claude Desktop doesn't source shell env — patch apiBaseUrl directly in config
414
422
  # so all LLM calls route through the Guard proxy (PII blocking, spend limits, audit).
415
- _patch_claude_desktop_proxy(api_url, member_token)
423
+ _patch_claude_desktop_proxy(api_url, agent_token)
416
424
 
417
425
  # Cursor global rules — write Guard policies as user rules so they apply across all projects.
418
426
  _patch_cursor_global_rules()
419
427
 
420
428
 
421
- 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:
422
430
  """Patch Claude Desktop config to route LLM calls through the Guard proxy.
423
431
 
424
432
  Claude Desktop reads apiBaseUrl from its config JSON — it does not source
@@ -729,18 +737,18 @@ def cmd_guard_install(args):
729
737
  print(f" {GRAY}Guard not installed for this workspace — skipping{RESET}")
730
738
  return
731
739
 
732
- member_token = result.get("member_token") or ""
740
+ agent_token = result.get("agent_token") or ""
733
741
  user_email = result.get("user_email") or ""
734
742
  clerk_user_id = result.get("clerk_user_id") or ""
735
743
 
736
744
  # Persona selection — prompt once, skip if already chosen
737
745
  _ensure_persona(workspace_id, api_key, server)
738
746
 
739
- # Persist guard config — include api_key so CLI commands can authenticate
747
+ # Persist unified config — single ~/.conduct/config.json
740
748
  import time as _time
741
749
  _save_guard_config({
742
750
  "workspace_id": workspace_id,
743
- "member_token": member_token,
751
+ "agent_token": agent_token,
744
752
  "user_email": user_email,
745
753
  "clerk_user_id": clerk_user_id,
746
754
  "api_key": api_key,
@@ -777,7 +785,12 @@ def cmd_guard_install(args):
777
785
  _install_codex_hook(hook_path)
778
786
 
779
787
  # Register MCP in all found AI tools — Cursor/Windsurf (advisory)
780
- _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 "")
781
794
 
782
795
  # Install session persistence hooks (PreCompact + SessionStart)
783
796
  try:
@@ -812,7 +825,7 @@ def cmd_guard_install(args):
812
825
 
813
826
 
814
827
  def _write_signing_key(hex_key: str) -> None:
815
- """Write a hex-encoded signing key to ~/.conductguard/signing.key.
828
+ """Write a hex-encoded signing key to ~/.conduct/signing.key.
816
829
 
817
830
  Validates that the path resolves within GUARD_DIR (no traversal) and
818
831
  that the value is a valid 64-char hex string (32 bytes).
@@ -857,7 +870,7 @@ def cmd_guard_join(args):
857
870
  result = _req("POST", f"{base_url}/guard/join", body=payload)
858
871
 
859
872
  workspace_id = result["workspace_id"]
860
- member_token = result.get("member_token", "")
873
+ agent_token_install = result.get("agent_token", "")
861
874
  policy = result.get("policy", {"workspace_id": workspace_id, "version": "1", "rules": []})
862
875
 
863
876
  # Download and persist policy
@@ -873,8 +886,8 @@ def cmd_guard_join(args):
873
886
  "api_url": base_url,
874
887
  "last_synced_at": _time.time(),
875
888
  }
876
- if member_token:
877
- cfg["member_token"] = member_token
889
+ if agent_token_install:
890
+ cfg["agent_token"] = agent_token_install
878
891
  _save_guard_config(cfg)
879
892
 
880
893
  # Write hook script
@@ -892,18 +905,19 @@ def cmd_guard_join(args):
892
905
  )
893
906
 
894
907
 
895
- def _report_tools_to_server() -> None:
896
- """Detect AI coding tools on this machine and POST coverage to Guard API. Silent on failure."""
897
- 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()
898
911
 
899
- def _check_json_key(path: Path, *keys) -> bool:
900
- try:
901
- d = json.loads(path.read_text()) if path.exists() else {}
902
- for k in keys:
903
- d = d.get(k, {}) if isinstance(d, dict) else {}
904
- return bool(d) and isinstance(d, dict) and len(d) > 0
905
- except Exception:
906
- 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()
907
921
 
908
922
  def _check_json_mcp(path: Path) -> bool:
909
923
  try:
@@ -927,6 +941,20 @@ def _report_tools_to_server() -> None:
927
941
  except Exception:
928
942
  return False
929
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
+
930
958
  tools = []
931
959
 
932
960
  claude_dir = home / ".claude"
@@ -936,16 +964,27 @@ def _report_tools_to_server() -> None:
936
964
  "name": "claude-code",
937
965
  "mcp_registered": _check_json_mcp(settings),
938
966
  "hook_registered": _check_json_hook(settings),
967
+ "proxy_routed": _is_anthropic_proxied(),
939
968
  })
940
969
 
941
970
  codex_dir = home / ".codex"
942
971
  if codex_dir.exists():
943
972
  config = codex_dir / "config.toml"
944
- tools.append({
945
- "name": "codex",
946
- "mcp_registered": _check_toml_str(config, "conduct-mcp"),
947
- "hook_registered": _check_toml_str(config, "conductguard"),
948
- })
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
+ })
949
988
 
950
989
  cursor_dir = home / ".cursor"
951
990
  if cursor_dir.exists():
@@ -953,6 +992,7 @@ def _report_tools_to_server() -> None:
953
992
  "name": "cursor",
954
993
  "mcp_registered": _check_json_mcp(cursor_dir / "mcp.json"),
955
994
  "hook_registered": False,
995
+ "proxy_routed": _is_anthropic_proxied(),
956
996
  })
957
997
 
958
998
  windsurf_dir = home / ".codeium" / "windsurf"
@@ -961,6 +1001,19 @@ def _report_tools_to_server() -> None:
961
1001
  "name": "windsurf",
962
1002
  "mcp_registered": _check_json_mcp(windsurf_dir / "mcp_config.json"),
963
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(),
964
1017
  })
965
1018
 
966
1019
  vscode_ext_dir = home / ".vscode" / "extensions"
@@ -983,8 +1036,16 @@ def _report_tools_to_server() -> None:
983
1036
  "name": "vscode",
984
1037
  "mcp_registered": mcp_reg,
985
1038
  "hook_registered": False,
1039
+ "proxy_routed": False,
986
1040
  })
987
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
+
988
1049
  if not tools:
989
1050
  return
990
1051
 
@@ -992,7 +1053,7 @@ def _report_tools_to_server() -> None:
992
1053
  cfg = _load_guard_config()
993
1054
  base_url = _api_url(cfg)
994
1055
  email = cfg.get("user_email", "")
995
- token = cfg.get("member_token", "")
1056
+ token = cfg.get("agent_token", "")
996
1057
  api_key = cfg.get("api_key", "")
997
1058
 
998
1059
  if not email:
@@ -1163,15 +1224,14 @@ def cmd_guard_sync(args):
1163
1224
  f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
1164
1225
  api_key=api_key,
1165
1226
  )
1166
- fresh_token = installed.get("member_token") or ""
1167
- if fresh_token:
1168
- 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
1169
1230
  _save_guard_config(cfg)
1170
1231
  else:
1171
- 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}")
1172
1233
  except Exception as e:
1173
- fresh_token = ""
1174
- 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}")
1175
1235
 
1176
1236
  # Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
1177
1237
  # routes through Conduct Guard. Customer-overridable via --proxy-url or
@@ -1181,7 +1241,7 @@ def cmd_guard_sync(args):
1181
1241
  try:
1182
1242
  import urllib.request as _ur, urllib.error as _ue
1183
1243
  _headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
1184
- _token = cfg.get("member_token", "")
1244
+ _token = cfg.get("agent_token", "")
1185
1245
  if _token:
1186
1246
  _headers["Authorization"] = f"Bearer {_token}"
1187
1247
  _r = _ur.Request(f"{base_url}/guard/proxy-config", headers=_headers)
@@ -1189,13 +1249,29 @@ def cmd_guard_sync(args):
1189
1249
  proxy_url = json.loads(_resp.read()).get("conduct_proxy_url") or DEFAULT_PROXY_URL
1190
1250
  except Exception:
1191
1251
  proxy_url = DEFAULT_PROXY_URL
1192
- member_token = cfg.get("member_token", "")
1193
- rc_path, newly_sourced = _write_proxy_env(member_token, proxy_url)
1194
- 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:
1195
1255
  print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/env → {proxy_url}")
1196
1256
  if newly_sourced:
1197
1257
  print(f" {CYAN}Run `source {rc_path}` (or open a new shell) to activate.{RESET}")
1198
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
+
1199
1275
  # Local key audit — scan known config files for real provider keys that
1200
1276
  # may have been pasted before the dev onboarded onto Conduct. Findings
1201
1277
  # surface in /guard/audit with a 'LOCAL RISK' badge. --no-local-audit
@@ -1220,7 +1296,7 @@ def cmd_guard_sync(args):
1220
1296
  _install_claude_hook(hook_path)
1221
1297
  _install_codex_hook(hook_path)
1222
1298
  cfg2 = _load_guard_config()
1223
- _register_mcp(workspace_id, cfg2.get("member_token", ""), base_url)
1299
+ _register_mcp(workspace_id, cfg2.get("agent_token", ""), base_url)
1224
1300
  try:
1225
1301
  _install_session_hooks()
1226
1302
  except Exception:
@@ -1254,13 +1330,13 @@ def cmd_guard_sync(args):
1254
1330
  print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
1255
1331
 
1256
1332
  # Print Claude.ai remote MCP URL — requires one-time browser paste
1257
- member_token = cfg.get("member_token", "")
1258
- if workspace_id and member_token:
1333
+ agent_token = cfg.get("agent_token", "")
1334
+ if workspace_id and agent_token:
1259
1335
  mcp_url = "https://api.conductai.ai/guard/mcp"
1260
1336
  print(f"\n{BOLD}Claude.ai{RESET} (one-time browser setup):")
1261
1337
  print(f" Settings → MCP Servers → Add custom server")
1262
1338
  print(f" URL: {CYAN}{mcp_url}{RESET}")
1263
- print(f" Auth: {CYAN}Bearer {member_token}{RESET}\n")
1339
+ print(f" Auth: {CYAN}Bearer {agent_token}{RESET}\n")
1264
1340
 
1265
1341
 
1266
1342
  CONDUCT_DIR = Path.home() / ".conduct"
@@ -1349,13 +1425,13 @@ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None
1349
1425
  "findings": findings,
1350
1426
  },
1351
1427
  api_key=cfg.get("api_key", ""),
1352
- token=cfg.get("member_token", ""),
1428
+ token=cfg.get("agent_token", ""),
1353
1429
  )
1354
1430
  except Exception as e:
1355
1431
  print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
1356
1432
 
1357
1433
 
1358
- 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]:
1359
1435
  """Write ~/.conduct/env with the 3 provider env-var pairs and ensure the
1360
1436
  user's shell rc sources it.
1361
1437
 
@@ -1364,12 +1440,12 @@ def _write_proxy_env(member_token: str, proxy_url: str) -> tuple[Path, bool]:
1364
1440
  User-managed customizations go in ~/.conduct/env-override which is sourced
1365
1441
  after the managed file.
1366
1442
  """
1367
- if not member_token:
1368
- 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}")
1369
1445
  return Path(), False
1370
1446
 
1371
1447
  CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
1372
- token = f"guard-mt-{member_token}"
1448
+ token = agent_token # cond_agt_* used directly as the API key value
1373
1449
  proxy = proxy_url.rstrip("/")
1374
1450
 
1375
1451
  PROXY_ENV_FILE.write_text("\n".join([
@@ -1614,10 +1690,10 @@ def _report_savings(cfg: dict, base_url: str, api_key: str) -> None:
1614
1690
  }
1615
1691
 
1616
1692
  try:
1617
- member_token = cfg.get("member_token", "")
1693
+ agent_token_evt = cfg.get("agent_token", "")
1618
1694
  headers = {"Content-Type": "application/json"}
1619
- if member_token:
1620
- headers["Authorization"] = f"Bearer {member_token}"
1695
+ if agent_token_evt:
1696
+ headers["Authorization"] = f"Bearer {agent_token_evt}"
1621
1697
  elif api_key:
1622
1698
  headers["X-Api-Key"] = api_key
1623
1699
  data = json.dumps(payload).encode()
@@ -1932,7 +2008,7 @@ def register_guard_parser(sub):
1932
2008
  default=None,
1933
2009
  metavar="HEX",
1934
2010
  help="Hex-encoded 32-byte signing key from POST /workspaces/{id}/signing-key. "
1935
- "Written to ~/.conductguard/signing.key.",
2011
+ "Written to ~/.conduct/signing.key.",
1936
2012
  )
1937
2013
 
1938
2014
  # conduct guard booster-status
@@ -1965,7 +2041,7 @@ def register_guard_parser(sub):
1965
2041
  # conduct guard lint [--file PATH]
1966
2042
  lint_p = guard_sub.add_parser("lint", help="Validate local policy — show errors and warnings")
1967
2043
  lint_p.add_argument("--file", default=None, metavar="FILE",
1968
- help="Path to policy YAML/JSON (default: ~/.conductguard/policy.json)")
2044
+ help="Path to policy YAML/JSON (default: ~/.conduct/policy.json)")
1969
2045
 
1970
2046
  return guard_p, guard_sub
1971
2047
 
@@ -2101,8 +2177,8 @@ def _scan_processes() -> list[dict]:
2101
2177
  return found
2102
2178
 
2103
2179
 
2104
- _WATCH_PID = Path.home() / ".conductguard" / "watch.pid"
2105
- _WATCH_LOG = Path.home() / ".conductguard" / "watch.log"
2180
+ _WATCH_PID = Path.home() / ".conduct" / "watch.pid"
2181
+ _WATCH_LOG = Path.home() / ".conduct" / "watch.log"
2106
2182
  _WATCH_INTERVAL = 15 * 60 # seconds
2107
2183
 
2108
2184
 
@@ -2130,7 +2206,7 @@ def _watch_loop():
2130
2206
  try:
2131
2207
  c = _cfg()
2132
2208
  api_url = _api_url(c)
2133
- token = c.get("member_token", "")
2209
+ token = c.get("agent_token", "")
2134
2210
  api_key = c.get("api_key", "")
2135
2211
  agents = []
2136
2212
  for name, config_path, mcp_key in _discover_config_agents():
@@ -2306,7 +2382,7 @@ def cmd_guard_discover(args):
2306
2382
 
2307
2383
  cfg = _load_guard_config()
2308
2384
  api_url = _api_url(cfg)
2309
- token = cfg.get("member_token", "")
2385
+ token = cfg.get("agent_token", "")
2310
2386
  api_key = cfg.get("api_key", "")
2311
2387
  hdrs = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
2312
2388
 
@@ -2357,7 +2433,16 @@ def cmd_guard_discover(args):
2357
2433
 
2358
2434
  # POST to API
2359
2435
  try:
2360
- 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}
2361
2446
  _req("POST", f"{api_url}/guard/discover/scan", body=payload, token=token, api_key=api_key)
2362
2447
  except SystemExit:
2363
2448
  pass # 401/network errors — local output still useful
@@ -12,6 +12,7 @@ Exposes three tools:
12
12
  guard_sync — pull latest policy from the ConductGuard API
13
13
  """
14
14
  from __future__ import annotations
15
+ from conduct_cli.tool_groups import expand_match_tool
15
16
  import argparse
16
17
  import json
17
18
  import re
@@ -183,14 +184,23 @@ def _maybe_sync() -> None:
183
184
  def _detect_surface(client_info: dict) -> str:
184
185
  """Map MCP clientInfo.name → ai_tool label sent to Guard API."""
185
186
  name = (client_info.get("name") or "").lower()
186
- if "desktop" in name:
187
- return "claude_desktop"
187
+ # ── Anthropic ─────────────────────────────────────────────────────────────
188
+ if "code" in name and "claude" in name:
189
+ return "claude-code"
190
+ if "desktop" in name and "claude" in name:
191
+ return "claude-desktop"
188
192
  if "work" in name or "teams" in name or "enterprise" in name:
189
- return "claude_work"
193
+ return "claude-desktop" # Claude for Work / Teams
190
194
  if "claude" in name:
191
- return "claude_chat"
195
+ return "claude-ai" # claude.ai web
196
+ # ── OpenAI ────────────────────────────────────────────────────────────────
197
+ if "chatgpt" in name or "openai" in name:
198
+ return "openai-chatgpt"
199
+ if "codex" in name and "desktop" in name:
200
+ return "codex-desktop"
192
201
  if "codex" in name:
193
- return "codex"
202
+ return "codex-cli"
203
+ # ── Other IDEs ────────────────────────────────────────────────────────────
194
204
  if "cursor" in name:
195
205
  return "cursor"
196
206
  if "windsurf" in name:
@@ -259,9 +269,13 @@ def _match_policy(tool_name: str, tool_input: dict) -> dict | None:
259
269
  for rule in rules:
260
270
  match_tool = (rule.get("match_tool") or "*").lower()
261
271
  if match_tool != "*":
262
- allowed = [t.strip() for t in match_tool.split(",")]
263
- if tool_name.lower() not in allowed:
272
+ if tool_name.lower() not in expand_match_tool(match_tool):
264
273
  continue
274
+ match_ai = rule.get("match_ai_tool")
275
+ if match_ai:
276
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
277
+ if not any(s in ai_tool.lower() for s in surfaces):
278
+ continue # rule targets a different surface
265
279
 
266
280
  pattern = rule.get("match_pattern")
267
281
  if pattern:
@@ -74,24 +74,38 @@ def detect_repo() -> Optional[str]:
74
74
 
75
75
 
76
76
  def detect_ai_tool() -> str:
77
- """Return a string identifying the active AI coding tool."""
77
+ """Return a string identifying the active AI coding tool / surface."""
78
78
  import os
79
+ # ── Anthropic ─────────────────────────────────────────────────────────────
79
80
  if os.environ.get("CLAUDE_CODE_ENTRYPOINT") or os.environ.get("CLAUDECODE"):
80
81
  return "claude-code"
81
- if os.environ.get("CODEX_SESSION_ID") or os.environ.get("CODEX_CLI_VERSION"):
82
- return "codex"
82
+ if os.environ.get("CLAUDE_DESKTOP_ENTRYPOINT") or os.environ.get("CLAUDE_DESKTOP"):
83
+ return "claude-desktop"
84
+ # ── OpenAI / Codex ────────────────────────────────────────────────────────
85
+ if os.environ.get("CODEX_CLI_VERSION") or os.environ.get("CODEX_SESSION_ID"):
86
+ return "codex-cli"
87
+ if os.environ.get("CODEX_DESKTOP") or os.environ.get("OPENAI_CODEX_DESKTOP"):
88
+ return "codex-desktop"
89
+ if os.environ.get("OPENAI_CHATGPT_MCP") or os.environ.get("CHATGPT_SESSION_ID"):
90
+ return "openai-chatgpt"
91
+ # ── TERM_PROGRAM ──────────────────────────────────────────────────────────
83
92
  term = os.environ.get("TERM_PROGRAM", "").lower()
84
93
  if term == "cursor":
85
94
  return "cursor"
86
95
  if term == "windsurf":
87
96
  return "windsurf"
97
+ # ── PATH heuristics ───────────────────────────────────────────────────────
88
98
  path = os.environ.get("PATH", "")
89
- if "Codex.app" in path or "codex" in path.lower():
90
- return "codex"
99
+ if "Codex.app" in path:
100
+ return "codex-desktop"
101
+ if "codex" in path.lower():
102
+ return "codex-cli"
91
103
  if "cursor" in path.lower():
92
104
  return "cursor"
93
105
  if "windsurf" in path.lower():
94
106
  return "windsurf"
107
+ if "claude" in path.lower():
108
+ return "claude-desktop"
95
109
  return "claude-code"
96
110
 
97
111
 
@@ -10,6 +10,7 @@ import time
10
10
  import urllib.request
11
11
  from pathlib import Path
12
12
 
13
+ from conduct_cli.tool_groups import expand_match_tool
13
14
  from conduct_cli.hooks.base import (
14
15
  BUDGET_CACHE_PATH,
15
16
  BUDGET_CACHE_TTL,
@@ -325,10 +326,16 @@ def check_policy(tool_name: str, tool_input: dict, tokens_before: int = 0):
325
326
  input_text = json.dumps(tool_input)
326
327
  path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
327
328
 
329
+ current_ai_tool = detect_ai_tool()
328
330
  for rule in rules:
329
331
  match_tool = (rule.get("match_tool") or "*").lower()
330
332
  if match_tool != "*":
331
- if tool_name not in [t.strip() for t in match_tool.split(",")]:
333
+ if tool_name not in expand_match_tool(match_tool):
334
+ continue
335
+ match_ai = rule.get("match_ai_tool")
336
+ if match_ai:
337
+ surfaces = [s.strip().lower() for s in match_ai.split(",")]
338
+ if not any(s in current_ai_tool.lower() for s in surfaces):
332
339
  continue
333
340
  pattern = rule.get("match_pattern")
334
341
  if pattern:
@@ -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"
@@ -0,0 +1,58 @@
1
+ """
2
+ Canonical tool group mapping — single source of truth for semantic match_tool names.
3
+ Shared across CLI hook, MCP, and API (mirrored at apps/api/app/modules/guard/tool_groups.py).
4
+ """
5
+
6
+ TOOL_GROUPS: dict[str, set[str]] = {
7
+ "shell": {"bash", "run_command", "execute", "terminal", "shell"},
8
+ "filesystem-write": {"write", "edit", "write_file", "edit_file", "str_replace_editor"},
9
+ "filesystem-read": {"read", "read_file", "glob", "grep", "list_directory"},
10
+ "network": {"web_fetch", "web_search", "http_request", "curl", "fetch"},
11
+ }
12
+
13
+ # Inverted: raw tool name → semantic group
14
+ _RAW_TO_GROUP: dict[str, str] = {
15
+ raw: group
16
+ for group, raws in TOOL_GROUPS.items()
17
+ for raw in raws
18
+ }
19
+
20
+
21
+ def normalize_tool(tool_name: str) -> str:
22
+ """Map a raw tool name to its semantic group, or return as-is if unknown."""
23
+ return _RAW_TO_GROUP.get(tool_name.lower(), tool_name.lower())
24
+
25
+
26
+ def expand_match_tool(match_tool: str) -> set[str]:
27
+ """
28
+ Expand a match_tool value (semantic group or raw name, comma-separated)
29
+ into the full set of raw tool names it covers.
30
+ """
31
+ result: set[str] = set()
32
+ for token in match_tool.split(","):
33
+ token = token.strip().lower()
34
+ if token == "*":
35
+ return {"*"}
36
+ if token in TOOL_GROUPS:
37
+ result |= TOOL_GROUPS[token]
38
+ else:
39
+ result.add(token)
40
+ return result
41
+
42
+
43
+ def collapse_to_group(match_tool: str) -> str:
44
+ """
45
+ Collapse a comma-separated match_tool value to semantic group names.
46
+ e.g. "write,edit" → "filesystem-write"
47
+ "bash,run_command" → "shell"
48
+ "bash,write" → "shell,filesystem-write"
49
+ """
50
+ if not match_tool or match_tool.strip() == "*":
51
+ return "*"
52
+ tokens = [t.strip().lower() for t in match_tool.split(",") if t.strip()]
53
+ groups_seen: list[str] = []
54
+ for token in tokens:
55
+ group = _RAW_TO_GROUP.get(token, token)
56
+ if group not in groups_seen:
57
+ groups_seen.append(group)
58
+ return ",".join(groups_seen)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.14
3
+ Version: 0.7.19
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
@@ -10,6 +10,7 @@ src/conduct_cli/main.py
10
10
  src/conduct_cli/mcp_server.py
11
11
  src/conduct_cli/memory.py
12
12
  src/conduct_cli/paxel.py
13
+ src/conduct_cli/tool_groups.py
13
14
  src/conduct_cli.egg-info/PKG-INFO
14
15
  src/conduct_cli.egg-info/SOURCES.txt
15
16
  src/conduct_cli.egg-info/dependency_links.txt
@@ -35,6 +36,7 @@ tests/test_guard_savings.py
35
36
  tests/test_hook_dispatch.py
36
37
  tests/test_journal_drain.py
37
38
  tests/test_log_util.py
39
+ tests/test_proxy_coverage.py
38
40
  tests/test_proxy_env.py
39
41
  tests/test_signed_policy.py
40
42
  tests/test_switch.py
@@ -542,3 +542,65 @@ class TestInvalidStdin:
542
542
  with pytest.raises(SystemExit) as exc:
543
543
  pt.main()
544
544
  assert exc.value.code == 0
545
+
546
+
547
+ # ── Fail-closed: API unreachable (budget check path) ──────────────────────────
548
+
549
+ class TestFailClosedApiUnreachable:
550
+ """fail_closed + policy exists + budget API unreachable → blocks with clear message."""
551
+
552
+ def test_api_unreachable_blocks_with_clear_message(self, monkeypatch, tmp_path, capsys):
553
+ import conduct_cli.hooks.pretooluse as pt
554
+ from io import StringIO
555
+
556
+ policy_file = tmp_path / "policy.json"
557
+ policy_file.write_text('{"rules": [], "fail_mode": "fail_closed"}')
558
+
559
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
560
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_closed")
561
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
562
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
563
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
564
+ # Budget cache miss, then API unreachable → returns (False, None)
565
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
566
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
567
+
568
+ posted: list[dict] = []
569
+ def _fake_post(tn, ti, decision, rule_id, msg, sid, **kw):
570
+ posted.append({"decision": decision, "rule_id": rule_id, "msg": msg})
571
+ monkeypatch.setattr(pt, "post_event", _fake_post)
572
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
573
+
574
+ with pytest.raises(SystemExit) as exc:
575
+ pt.main()
576
+
577
+ assert exc.value.code == 2
578
+ assert posted[0]["decision"] == "blocked"
579
+ assert posted[0]["rule_id"] == "guard-unavailable"
580
+ # Message must say "unreachable", not "budget"
581
+ assert "unreachable" in posted[0]["msg"].lower()
582
+ assert "budget" not in posted[0]["msg"].lower()
583
+
584
+ def test_api_unreachable_fail_open_passes(self, monkeypatch, tmp_path):
585
+ """Same scenario but fail_open → should NOT block."""
586
+ import conduct_cli.hooks.pretooluse as pt
587
+ from io import StringIO
588
+
589
+ policy_file = tmp_path / "policy.json"
590
+ policy_file.write_text('{"rules": []}')
591
+
592
+ monkeypatch.setattr(pt, "active_policy_path", lambda: policy_file)
593
+ monkeypatch.setattr(pt, "_get_fail_mode", lambda: "fail_open")
594
+ monkeypatch.setattr(pt, "_get_advisory_mode", lambda: False)
595
+ monkeypatch.setattr(pt, "_maybe_sync_policy", lambda: None)
596
+ monkeypatch.setattr(pt, "_should_periodic_flush", lambda: False)
597
+ monkeypatch.setattr(pt, "_load_budget_cache", lambda: (None, None))
598
+ monkeypatch.setattr(pt, "_fetch_budget_status", lambda: (False, None))
599
+ monkeypatch.setattr(pt, "post_event", lambda *a, **kw: None)
600
+ monkeypatch.setattr(sys, "stdin", StringIO(_hook_input()))
601
+
602
+ # Should not raise SystemExit(2) — exits 0 (allow)
603
+ try:
604
+ pt.main()
605
+ except SystemExit as e:
606
+ assert e.code == 0
@@ -97,22 +97,26 @@ class TestMatchTool:
97
97
  assert action == "warn"
98
98
 
99
99
  def test_exact_tool_match(self, policy_path):
100
- policy_path([_rule(match_tool="bash", action="warn")])
100
+ # semantic group "shell" matches raw tool "bash"
101
+ policy_path([_rule(match_tool="shell", action="warn")])
101
102
  _, action, _, _ = _check_policy("bash", {})
102
103
  assert action == "warn"
103
104
 
104
105
  def test_exact_tool_no_match(self, policy_path):
105
- policy_path([_rule(match_tool="edit", action="block")])
106
+ # "filesystem-write" does not match "bash"
107
+ policy_path([_rule(match_tool="filesystem-write", action="block")])
106
108
  _, action, _, _ = _check_policy("bash", {})
107
109
  assert action == "allow"
108
110
 
109
111
  def test_comma_separated_includes_tool(self, policy_path):
110
- policy_path([_rule(match_tool="bash,edit,write", action="warn")])
112
+ # "shell,filesystem-write" matches "edit" (filesystem-write group)
113
+ policy_path([_rule(match_tool="shell,filesystem-write", action="warn")])
111
114
  _, action, _, _ = _check_policy("edit", {})
112
115
  assert action == "warn"
113
116
 
114
117
  def test_comma_separated_excludes_tool(self, policy_path):
115
- policy_path([_rule(match_tool="bash,edit,write", action="warn")])
118
+ # "shell,filesystem-write" does not match "read" (filesystem-read group)
119
+ policy_path([_rule(match_tool="shell,filesystem-write", action="warn")])
116
120
  _, action, _, _ = _check_policy("read", {})
117
121
  assert action == "allow"
118
122
 
@@ -122,6 +126,42 @@ class TestMatchTool:
122
126
  assert action == "warn"
123
127
 
124
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
+
125
165
  # ── match_pattern ─────────────────────────────────────────────────────────────
126
166
 
127
167
  class TestMatchPattern:
@@ -0,0 +1,79 @@
1
+ """Tests for proxy coverage detection in guard status."""
2
+ import io
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock, patch
6
+
7
+
8
+ def _run_coverage_block(env: dict, cfg: dict, env_file_exists: bool = True) -> str:
9
+ """Run only the proxy-coverage printing block from cmd_guard_status."""
10
+ import conduct_cli.guard as g
11
+
12
+ output = io.StringIO()
13
+ with patch.dict("os.environ", env, clear=True), \
14
+ patch.object(Path, "exists", return_value=env_file_exists):
15
+ proxy_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/") + "/proxy"
16
+ _env = env
17
+ anthropic_ok = proxy_url in _env.get("ANTHROPIC_BASE_URL", "")
18
+ openai_ok = proxy_url in _env.get("OPENAI_BASE_URL", "")
19
+ perplexity_ok = proxy_url in _env.get("PERPLEXITY_BASE_URL", "")
20
+
21
+ covered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if ok]
22
+ uncovered = [n for n, ok in [("Anthropic", anthropic_ok), ("OpenAI", openai_ok), ("Perplexity", perplexity_ok)] if not ok]
23
+
24
+ GREEN, YELLOW, RED, RESET, BOLD = "", "", "", "", ""
25
+
26
+ if covered and not uncovered:
27
+ line = f"active — {', '.join(covered)} routed through Guard proxy"
28
+ elif covered:
29
+ line = f"partial — {', '.join(covered)} covered · {', '.join(uncovered)} NOT intercepted"
30
+ elif env_file_exists:
31
+ line = "inactive — ~/.conduct/env exists but not sourced in this shell."
32
+ else:
33
+ line = "not configured — run: conduct guard sync"
34
+ return line
35
+
36
+
37
+ _CFG = {"api_url": "https://api.conductai.ai"}
38
+ _PROXY = "https://api.conductai.ai/proxy"
39
+
40
+
41
+ def test_all_three_active():
42
+ env = {
43
+ "ANTHROPIC_BASE_URL": _PROXY,
44
+ "OPENAI_BASE_URL": f"{_PROXY}/openai",
45
+ "PERPLEXITY_BASE_URL": f"{_PROXY}/perplexity",
46
+ }
47
+ line = _run_coverage_block(env, _CFG)
48
+ assert line.startswith("active")
49
+ assert "Anthropic" in line
50
+ assert "OpenAI" in line
51
+ assert "Perplexity" in line
52
+
53
+
54
+ def test_partial_coverage():
55
+ env = {"ANTHROPIC_BASE_URL": _PROXY}
56
+ line = _run_coverage_block(env, _CFG)
57
+ assert line.startswith("partial")
58
+ assert "Anthropic" in line
59
+ assert "NOT intercepted" in line
60
+ assert "OpenAI" in line
61
+
62
+
63
+ def test_inactive_env_file_exists():
64
+ line = _run_coverage_block({}, _CFG, env_file_exists=True)
65
+ assert line.startswith("inactive")
66
+ assert "~/.conduct/env" in line
67
+
68
+
69
+ def test_not_configured_no_env_file():
70
+ line = _run_coverage_block({}, _CFG, env_file_exists=False)
71
+ assert line.startswith("not configured")
72
+ assert "conduct guard sync" in line
73
+
74
+
75
+ def test_wrong_proxy_url_not_counted():
76
+ """Env var pointing to a different proxy should not count as covered."""
77
+ env = {"ANTHROPIC_BASE_URL": "https://some-other-proxy.io"}
78
+ line = _run_coverage_block(env, _CFG, env_file_exists=True)
79
+ assert line.startswith("inactive")
File without changes
File without changes
File without changes