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