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.
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/PKG-INFO +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/pyproject.toml +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/guard.py +148 -63
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/guardmcp.py +21 -7
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/base.py +19 -5
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/pretooluse.py +8 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/main.py +19 -51
- conduct_cli-0.7.19/src/conduct_cli/tool_groups.py +58 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_advisory_and_hook.py +62 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_guard_policy.py +44 -4
- conduct_cli-0.7.19/tests/test_proxy_coverage.py +79 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/README.md +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/setup.cfg +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/setup.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.14 → conduct_cli-0.7.19}/tests/test_switch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
~/.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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,
|
|
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 ~/.
|
|
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
|
-
|
|
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
|
|
877
|
-
cfg["
|
|
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
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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("
|
|
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
|
-
|
|
1167
|
-
if
|
|
1168
|
-
cfg["
|
|
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
|
|
1232
|
+
print(f" {YELLOW}Warning: server returned no token — proxy env may be stale{RESET}")
|
|
1172
1233
|
except Exception as e:
|
|
1173
|
-
|
|
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("
|
|
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
|
-
|
|
1193
|
-
rc_path, newly_sourced = _write_proxy_env(
|
|
1194
|
-
if
|
|
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("
|
|
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
|
-
|
|
1258
|
-
if workspace_id and
|
|
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 {
|
|
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("
|
|
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(
|
|
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
|
|
1368
|
-
print(f" {YELLOW}Proxy env skipped — no
|
|
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 =
|
|
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
|
-
|
|
1693
|
+
agent_token_evt = cfg.get("agent_token", "")
|
|
1618
1694
|
headers = {"Content-Type": "application/json"}
|
|
1619
|
-
if
|
|
1620
|
-
headers["Authorization"] = f"Bearer {
|
|
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 ~/.
|
|
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: ~/.
|
|
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() / ".
|
|
2105
|
-
_WATCH_LOG = Path.home() / ".
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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 "
|
|
193
|
+
return "claude-desktop" # Claude for Work / Teams
|
|
190
194
|
if "claude" in name:
|
|
191
|
-
return "
|
|
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
|
-
|
|
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("
|
|
82
|
-
return "
|
|
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
|
|
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
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
if
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() / ".
|
|
2853
|
-
_GUARD_SKIP = Path.home() / ".
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|