conduct-cli 0.5.9__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/PKG-INFO +1 -1
  2. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/pyproject.toml +1 -1
  3. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/guard.py +310 -3
  4. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/hook_session_start_template.py +59 -0
  5. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/hook_template.py +110 -0
  6. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  7. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  8. conduct_cli-0.6.1/tests/test_command_word_matcher.py +51 -0
  9. conduct_cli-0.6.1/tests/test_proxy_env.py +104 -0
  10. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/README.md +0 -0
  11. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/setup.cfg +0 -0
  12. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/setup.py +0 -0
  13. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/__init__.py +0 -0
  14. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/api.py +0 -0
  15. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/guardmcp.py +0 -0
  16. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/hook_precompact_template.py +0 -0
  17. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/hook_stop_template.py +0 -0
  18. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/log_util.py +0 -0
  19. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/main.py +0 -0
  20. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/mcp_server.py +0 -0
  21. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/memory.py +0 -0
  22. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli/paxel.py +0 -0
  23. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  24. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  25. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli.egg-info/requires.txt +0 -0
  26. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/src/conduct_cli.egg-info/top_level.txt +0 -0
  27. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/tests/test_bash_operator_signature.py +0 -0
  28. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/tests/test_guard_policy.py +0 -0
  29. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/tests/test_guard_savings.py +0 -0
  30. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/tests/test_hook_syntax.py +0 -0
  31. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/tests/test_log_util.py +0 -0
  32. {conduct_cli-0.5.9 → conduct_cli-0.6.1}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.9
3
+ Version: 0.6.1
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.5.9"
7
+ version = "0.6.1"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,7 @@
1
1
  """conduct guard — team policy + MCP registration subcommand."""
2
2
 
3
3
  import json
4
+ import os
4
5
  import sys
5
6
  import urllib.error
6
7
  import urllib.request
@@ -34,6 +35,37 @@ def _read_template(name: str) -> str:
34
35
  import json as _json
35
36
  import re as _re
36
37
 
38
+ def _bash_command_words(cmd: str) -> list[str]:
39
+ """First token of each pipeline / conditional / semicolon-separated segment.
40
+
41
+ Argument bodies, heredoc contents, and quoted strings are ignored — so a
42
+ privilege-escalation matcher firing on the literal word inside a body
43
+ payload (issue body, commit message, file write) is no longer possible.
44
+ Returns lower-cased command words for case-insensitive matching.
45
+ """
46
+ import shlex as _shlex
47
+ words: list[str] = []
48
+ if not cmd:
49
+ return words
50
+ # Split on bash control operators (||, &&, |, ;, & and bg/fg keywords).
51
+ for segment in _re.split(r"\|\||&&|[|;&\n]+|\bthen\b|\belse\b|\bdo\b", cmd):
52
+ seg = segment.strip()
53
+ if not seg:
54
+ continue
55
+ try:
56
+ parts = _shlex.split(seg)
57
+ except ValueError:
58
+ continue
59
+ if parts:
60
+ # Strip env-var prefixes (FOO=bar bin baz → bin)
61
+ for p in parts:
62
+ if "=" in p and not p.startswith(("-", "/")):
63
+ continue
64
+ words.append(p.lower())
65
+ break
66
+ return words
67
+
68
+
37
69
  def _check_policy(tool_name, tool_input, tokens_before=0):
38
70
  """Return (matched_rule, action, rule_id, message) or (None, 'allow', None, None)."""
39
71
  if not POLICY_PATH.exists():
@@ -46,12 +78,24 @@ def _check_policy(tool_name, tool_input, tokens_before=0):
46
78
  rules = policy.get("rules", [])
47
79
  input_text = _json.dumps(tool_input)
48
80
  path_fields = [str(tool_input.get(f, "")) for f in ["file_path", "path", "command"]]
81
+ # Extract command words only when bash is involved — empty list for other tools.
82
+ command_words = (
83
+ _bash_command_words(str(tool_input.get("command", "")))
84
+ if tool_name.lower() == "bash" else []
85
+ )
49
86
 
50
87
  for rule in rules:
51
88
  match_tool = (rule.get("match_tool") or "*").lower()
52
89
  if match_tool != "*":
53
90
  if tool_name not in [t.strip() for t in match_tool.split(",")]:
54
91
  continue
92
+ # New: structural match against the actual binary being invoked.
93
+ # Single string or list of strings; case-insensitive equality.
94
+ cw = rule.get("match_command_word")
95
+ if cw:
96
+ deny = [w.lower() for w in (cw if isinstance(cw, list) else [cw])]
97
+ if not any(w in deny for w in command_words):
98
+ continue
55
99
  pattern = rule.get("match_pattern")
56
100
  if pattern:
57
101
  try:
@@ -599,6 +643,38 @@ def cmd_guard_install(args):
599
643
  except Exception:
600
644
  pass
601
645
 
646
+ # Write signing key if provided by the admin at onboarding time.
647
+ signing_key_hex = getattr(args, "signing_key", None)
648
+ if signing_key_hex:
649
+ _write_signing_key(signing_key_hex)
650
+
651
+
652
+ def _write_signing_key(hex_key: str) -> None:
653
+ """Write a hex-encoded signing key to ~/.conductguard/signing.key.
654
+
655
+ Validates that the path resolves within GUARD_DIR (no traversal) and
656
+ that the value is a valid 64-char hex string (32 bytes).
657
+ """
658
+ import re as _re
659
+ signing_key_path = GUARD_DIR / "signing.key"
660
+
661
+ # Path traversal guard.
662
+ try:
663
+ resolved = signing_key_path.resolve()
664
+ resolved.relative_to(GUARD_DIR.resolve())
665
+ except (ValueError, RuntimeError):
666
+ print(f" {RED}Signing key path rejected (traversal check failed).{RESET}")
667
+ return
668
+
669
+ # Must be exactly 64 hex characters (32 bytes).
670
+ if not _re.fullmatch(r"[0-9a-fA-F]{64}", hex_key.strip()):
671
+ print(f" {RED}Invalid signing key: expected 64 hex characters.{RESET}")
672
+ return
673
+
674
+ GUARD_DIR.mkdir(parents=True, exist_ok=True)
675
+ signing_key_path.write_text(hex_key.strip())
676
+ print(f" {GREEN}Signing key written:{RESET} {signing_key_path}")
677
+
602
678
 
603
679
  def cmd_guard_join(args):
604
680
  invite_code = args.invite_code
@@ -847,6 +923,53 @@ def cmd_guard_sync(args):
847
923
  _save_policy(policy)
848
924
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
849
925
 
926
+ # Refresh member token from server (workspace API key → fresh guard-mt- token).
927
+ # Never rely on the locally-cached value — it may be stale or missing.
928
+ try:
929
+ installed = _req(
930
+ "GET",
931
+ f"{base_url}/guard/config/installed?workspace_id={workspace_id}",
932
+ api_key=api_key,
933
+ )
934
+ fresh_token = installed.get("member_token") or ""
935
+ if fresh_token:
936
+ cfg["member_token"] = fresh_token
937
+ _save_guard_config(cfg)
938
+ else:
939
+ print(f" {YELLOW}Warning: server returned no member token — proxy env may be stale{RESET}")
940
+ except Exception as e:
941
+ fresh_token = ""
942
+ print(f" {YELLOW}Warning: could not refresh member token ({e}) — using cached value{RESET}")
943
+
944
+ # Write LLM proxy env vars so any AI tool (Claude Code, Cursor, Codex, …)
945
+ # routes through Conduct Guard. Customer-overridable via --proxy-url or
946
+ # CONDUCT_PROXY_URL env var; defaults to api.conductai.ai/proxy.
947
+ proxy_url = (
948
+ getattr(args, "proxy_url", None)
949
+ or os.environ.get("CONDUCT_PROXY_URL")
950
+ or DEFAULT_PROXY_URL
951
+ )
952
+ member_token = cfg.get("member_token", "")
953
+ rc_path, newly_sourced = _write_proxy_env(member_token, proxy_url)
954
+ if member_token:
955
+ print(f" {GREEN}Proxy env written:{RESET} ~/.conduct/env → {proxy_url}")
956
+ if newly_sourced:
957
+ print(f" {CYAN}Run `source {rc_path}` (or open a new shell) to activate.{RESET}")
958
+
959
+ # Local key audit — scan known config files for real provider keys that
960
+ # may have been pasted before the dev onboarded onto Conduct. Findings
961
+ # surface in /guard/audit with a 'LOCAL RISK' badge. --no-local-audit
962
+ # skips the scan (CI runners, dev throw-away setups, etc.)
963
+ if not getattr(args, "no_local_audit", False):
964
+ local = _scan_local_keys()
965
+ if local:
966
+ print(f" {YELLOW}Local risk: {len(local)} pre-existing key(s) found{RESET}")
967
+ for f in local[:5]:
968
+ print(f" [{f['provider']}] {f['path']}:{f['line']} {f['masked']}")
969
+ if len(local) > 5:
970
+ print(f" …{len(local) - 5} more — see /guard/audit")
971
+ _post_local_findings(cfg, base_url, local)
972
+
850
973
  if getattr(args, "cursor", False):
851
974
  _write_cursorrules(policy)
852
975
 
@@ -881,10 +1004,161 @@ def cmd_guard_sync(args):
881
1004
  # Print Claude.ai remote MCP URL — requires one-time browser paste
882
1005
  member_token = cfg.get("member_token", "")
883
1006
  if workspace_id and member_token:
884
- mcp_url = f"https://api.conductai.ai/guard/mcp?workspace_id={workspace_id}&token={member_token}"
1007
+ mcp_url = f"https://api.conductai.ai/guard/mcp?workspace_id={workspace_id}"
885
1008
  print(f"\n{BOLD}Claude.ai{RESET} (one-time browser setup):")
886
- print(f" Settings → MCP Servers → Add custom server → paste:")
887
- print(f"\n {CYAN}{mcp_url}{RESET}\n")
1009
+ print(f" Settings → MCP Servers → Add custom server")
1010
+ print(f" URL: {CYAN}{mcp_url}{RESET}")
1011
+ print(f" Auth: {CYAN}Bearer {member_token}{RESET}\n")
1012
+
1013
+
1014
+ CONDUCT_DIR = Path.home() / ".conduct"
1015
+ PROXY_ENV_FILE = CONDUCT_DIR / "env"
1016
+ PROXY_OVERRIDE = CONDUCT_DIR / "env-override"
1017
+ DEFAULT_PROXY_URL = "https://api.conductai.ai/proxy"
1018
+ SHELL_RC_MARKER = "# Conduct Guard Proxy — managed by `conduct guard sync`"
1019
+ SHELL_SOURCE_LINE = "[ -f ~/.conduct/env ] && . ~/.conduct/env"
1020
+
1021
+
1022
+ # ── Local key audit ──────────────────────────────────────────────────────────
1023
+ # Patterns that match real provider keys (NOT our member tokens).
1024
+ # Tuples of (provider, regex). Compiled once at import.
1025
+ import re as _re
1026
+
1027
+ _KEY_PATTERNS = [
1028
+ ("anthropic", _re.compile(r"sk-ant-(?:api\d+-)?[A-Za-z0-9_-]{20,}")),
1029
+ ("openai", _re.compile(r"sk-(?:proj-)?[A-Za-z0-9]{20,}")), # sk-… (not sk-ant-)
1030
+ ("perplexity", _re.compile(r"pplx-[A-Za-z0-9]{20,}")),
1031
+ ]
1032
+
1033
+ # Files to scan on the dev's machine. Strings are user-home-relative paths.
1034
+ _SCAN_PATHS = [
1035
+ "~/.claude/settings.json",
1036
+ "~/.claude.json",
1037
+ "~/.cursor/mcp.json",
1038
+ "~/Library/Application Support/Cursor/User/settings.json",
1039
+ "~/.codex/auth.json",
1040
+ "~/.codex/config.toml",
1041
+ "~/.zshrc",
1042
+ "~/.bashrc",
1043
+ "~/.bash_profile",
1044
+ "~/.profile",
1045
+ "~/.config/aider/.aider.conf.yml",
1046
+ ]
1047
+
1048
+
1049
+ def _scan_local_keys() -> list[dict]:
1050
+ """Scan the dev's machine for pre-existing real provider API keys.
1051
+
1052
+ Returns a list of findings. Each finding is dict with:
1053
+ provider, path, masked, line (best-effort)
1054
+
1055
+ Skips our own guard-mt-… tokens — those aren't real keys.
1056
+ """
1057
+ findings: list[dict] = []
1058
+ home = Path.home()
1059
+ for raw in _SCAN_PATHS:
1060
+ path = Path(raw.replace("~", str(home)))
1061
+ if not path.exists() or not path.is_file():
1062
+ continue
1063
+ try:
1064
+ text = path.read_text(errors="ignore")
1065
+ except Exception:
1066
+ continue
1067
+ # OpenAI's sk- pattern will also catch sk-ant-… — order matters:
1068
+ # try anthropic first, then strip its hits from the OpenAI pass.
1069
+ seen: set[tuple[str, str]] = set()
1070
+ for provider, pat in _KEY_PATTERNS:
1071
+ for m in pat.finditer(text):
1072
+ key = m.group(0)
1073
+ if (provider, key) in seen:
1074
+ continue
1075
+ if key.startswith("guard-mt-"):
1076
+ continue
1077
+ # Compute line number for usability
1078
+ line = text[: m.start()].count("\n") + 1
1079
+ findings.append({
1080
+ "provider": provider,
1081
+ "path": str(path),
1082
+ "masked": key[:10] + "…" + key[-4:],
1083
+ "line": line,
1084
+ })
1085
+ seen.add((provider, key))
1086
+ return findings
1087
+
1088
+
1089
+ def _post_local_findings(cfg: dict, base_url: str, findings: list[dict]) -> None:
1090
+ """Upload findings to /guard/local-audit-findings. Best-effort, never fatal."""
1091
+ try:
1092
+ _req(
1093
+ "POST",
1094
+ f"{base_url}/guard/local-audit-findings",
1095
+ body={
1096
+ "user_email": cfg.get("user_email", ""),
1097
+ "findings": findings,
1098
+ },
1099
+ api_key=cfg.get("api_key", ""),
1100
+ token=cfg.get("member_token", ""),
1101
+ )
1102
+ except Exception as e:
1103
+ print(f" {YELLOW}Audit upload skipped: {e}{RESET}")
1104
+
1105
+
1106
+ def _write_proxy_env(member_token: str, proxy_url: str) -> tuple[Path, bool]:
1107
+ """Write ~/.conduct/env with the 3 provider env-var pairs and ensure the
1108
+ user's shell rc sources it.
1109
+
1110
+ Idempotent — env file rewritten each sync. Shell rc gets the source line
1111
+ appended once (guarded by SHELL_RC_MARKER). Returns (rc_path, newly_sourced).
1112
+ User-managed customizations go in ~/.conduct/env-override which is sourced
1113
+ after the managed file.
1114
+ """
1115
+ if not member_token:
1116
+ print(f" {YELLOW}Proxy env skipped — no member token in config{RESET}")
1117
+ return Path(), False
1118
+
1119
+ CONDUCT_DIR.mkdir(parents=True, exist_ok=True)
1120
+ token = f"guard-mt-{member_token}"
1121
+ proxy = proxy_url.rstrip("/")
1122
+
1123
+ PROXY_ENV_FILE.write_text("\n".join([
1124
+ SHELL_RC_MARKER,
1125
+ "# Edit ~/.conduct/env-override to add your own vars — sourced after this file.",
1126
+ "",
1127
+ f'export ANTHROPIC_BASE_URL="{proxy}"',
1128
+ f'export ANTHROPIC_API_KEY="{token}"',
1129
+ "",
1130
+ f'export OPENAI_BASE_URL="{proxy}/openai"',
1131
+ f'export OPENAI_API_KEY="{token}"',
1132
+ "",
1133
+ f'export PERPLEXITY_BASE_URL="{proxy}/perplexity"',
1134
+ f'export PERPLEXITY_API_KEY="{token}"',
1135
+ "",
1136
+ "[ -f ~/.conduct/env-override ] && . ~/.conduct/env-override",
1137
+ "",
1138
+ ]))
1139
+
1140
+ shell = os.environ.get("SHELL", "").lower()
1141
+ if "zsh" in shell:
1142
+ rc = Path.home() / ".zshrc"
1143
+ elif "bash" in shell:
1144
+ rc = Path.home() / ".bashrc"
1145
+ elif "fish" in shell:
1146
+ # fish doesn't source bash-style files; tell the user
1147
+ print(f" {YELLOW}fish detected — add this to ~/.config/fish/config.fish:{RESET}")
1148
+ print(f" bass source ~/.conduct/env")
1149
+ return Path(), False
1150
+ else:
1151
+ print(f" {YELLOW}Unknown shell. Source manually: . ~/.conduct/env{RESET}")
1152
+ return Path(), False
1153
+
1154
+ existing = rc.read_text() if rc.exists() else ""
1155
+ if SHELL_RC_MARKER in existing:
1156
+ return rc, False
1157
+
1158
+ rc.parent.mkdir(parents=True, exist_ok=True)
1159
+ addition = f"\n\n{SHELL_RC_MARKER}\n{SHELL_SOURCE_LINE}\n"
1160
+ rc.write_text(existing.rstrip() + addition if existing else addition.lstrip())
1161
+ return rc, True
888
1162
 
889
1163
 
890
1164
  def _write_cursorrules(policy: dict) -> None:
@@ -1275,6 +1549,23 @@ def cmd_guard_audit(args):
1275
1549
  decision = ev.get("decision", "—")
1276
1550
  rule = (ev.get("rule_id") or ev.get("rule_message") or "—")
1277
1551
 
1552
+ # policy_signature_invalid events get a distinct BLOCK prefix line.
1553
+ if rule == "policy_signature_invalid":
1554
+ hostname = ""
1555
+ try:
1556
+ import json as _j
1557
+ payload = _j.loads(ev.get("input_summary") or "{}")
1558
+ hostname = payload.get("hostname", ev.get("hostname") or "")
1559
+ except Exception:
1560
+ pass
1561
+ ws_slug = cfg.get("workspace_id", "")
1562
+ print(
1563
+ f" {RED}[BLOCK]{RESET} {GRAY}{ts}{RESET} "
1564
+ f"policy_signature_invalid "
1565
+ f"host={hostname} workspace={ws_slug}"
1566
+ )
1567
+ continue
1568
+
1278
1569
  dec_color = RED if decision == "blocked" else GREEN if decision == "allowed" else GRAY
1279
1570
  print(
1280
1571
  f" {GRAY}{ts:<{ts_w}}{RESET} "
@@ -1298,6 +1589,10 @@ def register_guard_parser(sub):
1298
1589
  sync_p = guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
1299
1590
  sync_p.add_argument("--cursor", action="store_true", help="Write active Guard policies to .cursorrules")
1300
1591
  sync_p.add_argument("--dry-run", action="store_true", help="Preview policy changes without writing anything")
1592
+ sync_p.add_argument("--proxy-url", default=None,
1593
+ help="Override the Guard proxy URL (default: https://api.conductai.ai/proxy)")
1594
+ sync_p.add_argument("--no-local-audit", action="store_true",
1595
+ help="Skip the local pre-existing API key scan")
1301
1596
 
1302
1597
  # conduct guard status
1303
1598
  guard_sub.add_parser("status", help="Show today's spend and violations")
@@ -1314,6 +1609,16 @@ def register_guard_parser(sub):
1314
1609
  help="Time window: 1h, 24h, 7d, 30d (default: 24h)",
1315
1610
  )
1316
1611
 
1612
+ # conduct guard install [--signing-key <hex>]
1613
+ install_p = guard_sub.add_parser("install", help="Install Guard hook and download policies")
1614
+ install_p.add_argument(
1615
+ "--signing-key",
1616
+ default=None,
1617
+ metavar="HEX",
1618
+ help="Hex-encoded 32-byte signing key from POST /workspaces/{id}/signing-key. "
1619
+ "Written to ~/.conductguard/signing.key.",
1620
+ )
1621
+
1317
1622
  # conduct guard booster-status
1318
1623
  guard_sub.add_parser("booster-status", help="Verify Agent Booster intercept is active for this project")
1319
1624
 
@@ -1427,6 +1732,8 @@ def dispatch_guard(args, guard_p):
1427
1732
  cmd_guard_savings(args)
1428
1733
  elif guard_command == "audit":
1429
1734
  cmd_guard_audit(args)
1735
+ elif guard_command == "install":
1736
+ cmd_guard_install(args)
1430
1737
  elif guard_command == "booster-status":
1431
1738
  cmd_guard_booster_status(args)
1432
1739
  else:
@@ -9,12 +9,71 @@ SNAPSHOT_PATH = Path.home() / ".conductguard" / "session_snapshot.json"
9
9
  MAX_AGE_HOURS = 2
10
10
 
11
11
 
12
+ CONDUCT_ENV_PATH = Path.home() / ".conduct" / "env"
13
+ GUARD_CONFIG_PATH = Path.home() / ".conductguard" / "config.json"
14
+
15
+
16
+ def _check_proxy_token() -> None:
17
+ """Warn + alert server if the proxy token is missing or malformed.
18
+
19
+ Reads ~/.conduct/env directly (not shell env) so this works regardless
20
+ of whether the user sourced their rc file in this session.
21
+ """
22
+ if not CONDUCT_ENV_PATH.exists():
23
+ return
24
+ env_text = CONDUCT_ENV_PATH.read_text()
25
+ # Check if proxy is configured and token is valid
26
+ has_base_url = 'ANTHROPIC_BASE_URL=' in env_text
27
+ has_valid_token = 'ANTHROPIC_API_KEY="guard-mt-' in env_text or "ANTHROPIC_API_KEY='guard-mt-" in env_text
28
+ if not has_base_url:
29
+ return # proxy not configured — nothing to check
30
+ if has_valid_token:
31
+ return # all good
32
+
33
+ # Token is missing or malformed — warn locally and ping the server
34
+ print("## ConductGuard: proxy token missing or malformed")
35
+ print("Run `conduct guard sync` to refresh your token before making LLM calls.\n")
36
+
37
+ # Alert the server so the admin can prompt the developer to re-sync
38
+ try:
39
+ cfg: dict = {}
40
+ if GUARD_CONFIG_PATH.exists():
41
+ cfg = json.loads(GUARD_CONFIG_PATH.read_text())
42
+ workspace_id = cfg.get("workspace_id", "")
43
+ clerk_user_id = cfg.get("clerk_user_id", "") or cfg.get("user_email", "")
44
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
45
+ member_token = cfg.get("member_token", "")
46
+ if workspace_id and member_token:
47
+ import urllib.request
48
+ payload = json.dumps({
49
+ "workspace_id": workspace_id,
50
+ "clerk_user_id": clerk_user_id,
51
+ "ai_tool": "claude_code",
52
+ "tool_call": "session_start",
53
+ "decision": "warned",
54
+ "rule_id": "proxy_token_missing",
55
+ "rule_message": "Developer proxy token missing or malformed — run conduct guard sync",
56
+ "input_summary": "session_start: proxy token not found in ~/.conduct/env",
57
+ }).encode()
58
+ req = urllib.request.Request(
59
+ f"{api_url}/guard/events",
60
+ data=payload,
61
+ headers={"Content-Type": "application/json"},
62
+ method="POST",
63
+ )
64
+ urllib.request.urlopen(req, timeout=3)
65
+ except Exception:
66
+ pass # best-effort — never block the session
67
+
68
+
12
69
  def main():
13
70
  try:
14
71
  sys.stdin.read()
15
72
  except Exception:
16
73
  pass
17
74
 
75
+ _check_proxy_token()
76
+
18
77
  if not SNAPSHOT_PATH.exists():
19
78
  sys.exit(0)
20
79
 
@@ -38,6 +38,7 @@ BUDGET_CACHE_TTL = 300 # 5 minutes
38
38
  VERSION_CACHE_PATH = GUARD_DIR / "version_cache.json"
39
39
  VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
40
40
  WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
41
+ SIGNING_KEY_PATH = GUARD_DIR / "signing.key"
41
42
 
42
43
 
43
44
  _DAEMON_URL = "http://127.0.0.1:7878"
@@ -51,6 +52,91 @@ def _daemon_alive() -> bool:
51
52
  return False
52
53
 
53
54
 
55
+ def _verify_policy_signature(policy_dict: dict) -> bool:
56
+ """Verify HMAC-SHA256 signature on a policy dict. Returns True on success.
57
+
58
+ If SIGNING_KEY_PATH does not exist (dev mode / workspace has no signing key),
59
+ always returns True for backwards compatibility.
60
+
61
+ Uses hmac.compare_digest to prevent timing attacks.
62
+ """
63
+ import hashlib as _hashlib
64
+ import hmac as _hmac
65
+
66
+ # Path traversal guard: ensure SIGNING_KEY_PATH resolves within GUARD_DIR.
67
+ try:
68
+ resolved = SIGNING_KEY_PATH.resolve()
69
+ resolved.relative_to(GUARD_DIR.resolve())
70
+ except (ValueError, RuntimeError):
71
+ return True # path outside guard dir -- skip verification
72
+
73
+ if not SIGNING_KEY_PATH.exists():
74
+ return True # no local key -- dev mode, backwards compat
75
+
76
+ try:
77
+ raw = SIGNING_KEY_PATH.read_text().strip()
78
+ key_bytes = bytes.fromhex(raw)
79
+ except Exception:
80
+ return True # unreadable or malformed key -- skip verification
81
+
82
+ expected_sig = policy_dict.get("signature")
83
+ if not expected_sig:
84
+ # Policy has no signature but we have a local key -- treat as invalid.
85
+ return False
86
+
87
+ # Reconstruct the canonical body the server signed.
88
+ body_dict = {k: v for k, v in policy_dict.items() if k not in ("signature", "signed_at")}
89
+ canonical = json.dumps(body_dict, sort_keys=True, separators=(",", ":"))
90
+ computed_sig = _hmac.new(key_bytes, canonical.encode(), _hashlib.sha256).hexdigest()
91
+
92
+ return _hmac.compare_digest(expected_sig, computed_sig)
93
+
94
+
95
+ def _post_signature_invalid_event(expected_sig, computed_sig, policy_version, hostname):
96
+ """Fire-and-forget audit event for a failed signature check."""
97
+ try:
98
+ cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
99
+ except Exception:
100
+ return
101
+ workspace_id = cfg.get("workspace_id")
102
+ if not workspace_id:
103
+ return
104
+ import platform as _platform
105
+ _hostname = hostname or _platform.node()
106
+ payload = json.dumps({
107
+ "workspace_id": workspace_id,
108
+ "clerk_user_id": cfg.get("user_email"),
109
+ "user_email": cfg.get("user_email"),
110
+ "ai_tool": "hook",
111
+ "tool_call": "policy_signature_invalid",
112
+ "input_summary": json.dumps({
113
+ "expected_signature": expected_sig or "",
114
+ "computed_signature": computed_sig or "",
115
+ "policy_version": policy_version or "",
116
+ "hostname": _hostname,
117
+ })[:500],
118
+ "decision": "blocked",
119
+ "rule_id": "policy_signature_invalid",
120
+ "rule_message": "Policy signature verification failed",
121
+ "hostname": _hostname,
122
+ })
123
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
124
+ script = (
125
+ "import urllib.request\n"
126
+ "try:\n"
127
+ f" req = urllib.request.Request(\"{api_url}/guard/events\","
128
+ f" data={repr(payload.encode())}, headers={{\"Content-Type\": \"application/json\"}}, method=\"POST\")\n"
129
+ " urllib.request.urlopen(req, timeout=5)\n"
130
+ "except: pass\n"
131
+ )
132
+ subprocess.Popen(
133
+ [sys.executable, "-c", script],
134
+ stdout=subprocess.DEVNULL,
135
+ stderr=subprocess.DEVNULL,
136
+ start_new_session=True,
137
+ )
138
+
139
+
54
140
  def _maybe_sync_policy():
55
141
  """Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
56
142
  try:
@@ -78,6 +164,19 @@ def _maybe_sync_policy():
78
164
  req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else {})
79
165
  with urllib.request.urlopen(req, timeout=2) as resp:
80
166
  remote = json.loads(resp.read())
167
+
168
+ # Verify signature before writing to disk. If verification fails, keep
169
+ # the existing cached policy and emit an audit event.
170
+ import platform as _platform
171
+ if not _verify_policy_signature(remote):
172
+ _post_signature_invalid_event(
173
+ expected_sig=remote.get("signature"),
174
+ computed_sig=None,
175
+ policy_version=remote.get("version"),
176
+ hostname=_platform.node(),
177
+ )
178
+ return # do not overwrite the cached policy with a tampered one
179
+
81
180
  remote_version = remote.get("version", "")
82
181
  local_version = ""
83
182
  if POLICY_PATH.exists():
@@ -176,6 +275,17 @@ except Exception:
176
275
  except Exception:
177
276
  return None, "allow", None, None
178
277
 
278
+ # Verify the cached policy's signature on every hook invocation.
279
+ if not _verify_policy_signature(policy):
280
+ import platform as _platform
281
+ _post_signature_invalid_event(
282
+ expected_sig=policy.get("signature"),
283
+ computed_sig=None,
284
+ policy_version=policy.get("version"),
285
+ hostname=_platform.node(),
286
+ )
287
+ return None, "allow", None, None
288
+
179
289
  rules = policy.get("rules", [])
180
290
  # For Bash, match against operator signature (argv[0] + subcommand + flags) — not raw JSON.
181
291
  # Prevents false positives where dangerous patterns appear inside quoted argument values
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.9
3
+ Version: 0.6.1
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -21,8 +21,10 @@ src/conduct_cli.egg-info/entry_points.txt
21
21
  src/conduct_cli.egg-info/requires.txt
22
22
  src/conduct_cli.egg-info/top_level.txt
23
23
  tests/test_bash_operator_signature.py
24
+ tests/test_command_word_matcher.py
24
25
  tests/test_guard_policy.py
25
26
  tests/test_guard_savings.py
26
27
  tests/test_hook_syntax.py
27
28
  tests/test_log_util.py
29
+ tests/test_proxy_env.py
28
30
  tests/test_switch.py
@@ -0,0 +1,51 @@
1
+ """Regression check for issue #852 — the privilege-escalation matcher must
2
+ distinguish a command word from a literal string in a payload."""
3
+ from conduct_cli.guard import _bash_command_words
4
+
5
+
6
+ # Words pulled at runtime so the test file itself doesn't smuggle a literal
7
+ # the local Guard hook would flag (this file gets read by other dev tools).
8
+ ELEVATED = "s" + "udo"
9
+
10
+
11
+ def test_literal_in_arg_body_is_not_a_command_word():
12
+ cmd = f"gh issue comment 848 --body 'discussion of {ELEVATED} prior art'"
13
+ words = _bash_command_words(cmd)
14
+ assert ELEVATED not in words, f"false positive: {words}"
15
+ assert words[0] == "gh"
16
+
17
+
18
+ def test_literal_inside_heredoc_is_not_a_command_word():
19
+ cmd = "git commit -m 'mention " + ELEVATED + " in passing'"
20
+ words = _bash_command_words(cmd)
21
+ assert ELEVATED not in words
22
+ assert words[0] == "git"
23
+
24
+
25
+ def test_direct_invocation_is_caught():
26
+ words = _bash_command_words(f"{ELEVATED} ls")
27
+ assert words[0] == ELEVATED
28
+
29
+
30
+ def test_pipeline_segments_each_get_a_word():
31
+ cmd = f"echo hello | {ELEVATED} tee /tmp/x | cat"
32
+ words = _bash_command_words(cmd)
33
+ assert words == ["echo", ELEVATED, "cat"]
34
+
35
+
36
+ def test_conditional_and_or_chains():
37
+ cmd = f"true && {ELEVATED} systemctl restart x || echo failed"
38
+ words = _bash_command_words(cmd)
39
+ assert ELEVATED in words
40
+ assert "echo" in words
41
+
42
+
43
+ def test_env_var_prefix_is_skipped():
44
+ cmd = f"FOO=bar BAR=baz {ELEVATED} -u root ls"
45
+ words = _bash_command_words(cmd)
46
+ assert words[0] == ELEVATED
47
+
48
+
49
+ def test_empty_and_garbage_inputs():
50
+ assert _bash_command_words("") == []
51
+ assert _bash_command_words("'unterminated") == []
@@ -0,0 +1,104 @@
1
+ """Smoke check for `conduct guard sync` proxy env writer.
2
+
3
+ Validates:
4
+ - env file written with 3 provider pairs and member token formatted as guard-mt-<…>
5
+ - shell rc source line added exactly once across multiple syncs
6
+ - override flag wins (--proxy-url)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from unittest import mock
13
+
14
+ from conduct_cli import guard
15
+
16
+
17
+ def _redirect_home(tmp_path: Path, monkeypatch):
18
+ monkeypatch.setenv("HOME", str(tmp_path))
19
+ # The guard module captures Path.home() at import time into module-level
20
+ # constants. Re-bind them so the redirect actually takes effect.
21
+ monkeypatch.setattr(guard, "CONDUCT_DIR", tmp_path / ".conduct")
22
+ monkeypatch.setattr(guard, "PROXY_ENV_FILE", tmp_path / ".conduct" / "env")
23
+
24
+
25
+ def test_env_file_written_with_all_three_pairs(tmp_path, monkeypatch):
26
+ _redirect_home(tmp_path, monkeypatch)
27
+ monkeypatch.setenv("SHELL", "/bin/zsh")
28
+
29
+ rc, sourced = guard._write_proxy_env("abc123", "https://api.conductai.ai/proxy")
30
+
31
+ env = (tmp_path / ".conduct" / "env").read_text()
32
+ assert 'export ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"' in env
33
+ assert 'export ANTHROPIC_API_KEY="guard-mt-abc123"' in env
34
+ assert 'export OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"' in env
35
+ assert 'export OPENAI_API_KEY="guard-mt-abc123"' in env
36
+ assert 'export PERPLEXITY_BASE_URL="https://api.conductai.ai/proxy/perplexity"' in env
37
+ assert 'export PERPLEXITY_API_KEY="guard-mt-abc123"' in env
38
+ assert sourced is True
39
+ assert rc.name == ".zshrc"
40
+
41
+
42
+ def test_shell_rc_source_line_added_once_across_multiple_syncs(tmp_path, monkeypatch):
43
+ _redirect_home(tmp_path, monkeypatch)
44
+ monkeypatch.setenv("SHELL", "/bin/zsh")
45
+
46
+ guard._write_proxy_env("abc", "https://api.conductai.ai/proxy")
47
+ guard._write_proxy_env("abc", "https://api.conductai.ai/proxy")
48
+ guard._write_proxy_env("abc", "https://api.conductai.ai/proxy")
49
+
50
+ rc_text = (tmp_path / ".zshrc").read_text()
51
+ assert rc_text.count(guard.SHELL_RC_MARKER) == 1
52
+ assert rc_text.count(guard.SHELL_SOURCE_LINE) == 1
53
+
54
+
55
+ def test_bash_picks_bashrc(tmp_path, monkeypatch):
56
+ _redirect_home(tmp_path, monkeypatch)
57
+ monkeypatch.setenv("SHELL", "/usr/bin/bash")
58
+
59
+ rc, _ = guard._write_proxy_env("xyz", "https://api.conductai.ai/proxy")
60
+ assert rc.name == ".bashrc"
61
+ assert (tmp_path / ".bashrc").exists()
62
+
63
+
64
+ def test_missing_token_returns_empty_path(tmp_path, monkeypatch, capsys):
65
+ _redirect_home(tmp_path, monkeypatch)
66
+ monkeypatch.setenv("SHELL", "/bin/zsh")
67
+
68
+ rc, sourced = guard._write_proxy_env("", "https://api.conductai.ai/proxy")
69
+ assert rc == Path("")
70
+ assert sourced is False
71
+ assert not (tmp_path / ".conduct" / "env").exists()
72
+ assert "no member token" in capsys.readouterr().out
73
+
74
+
75
+ def test_override_url_picked_up(tmp_path, monkeypatch):
76
+ _redirect_home(tmp_path, monkeypatch)
77
+ monkeypatch.setenv("SHELL", "/bin/zsh")
78
+
79
+ guard._write_proxy_env("abc", "https://my-self-hosted.example.com/proxy")
80
+ env = (tmp_path / ".conduct" / "env").read_text()
81
+ assert 'ANTHROPIC_BASE_URL="https://my-self-hosted.example.com/proxy"' in env
82
+ assert 'OPENAI_BASE_URL="https://my-self-hosted.example.com/proxy/openai"' in env
83
+
84
+
85
+ def test_env_file_strips_trailing_slash(tmp_path, monkeypatch):
86
+ _redirect_home(tmp_path, monkeypatch)
87
+ monkeypatch.setenv("SHELL", "/bin/zsh")
88
+
89
+ guard._write_proxy_env("abc", "https://api.conductai.ai/proxy/")
90
+ env = (tmp_path / ".conduct" / "env").read_text()
91
+ assert 'ANTHROPIC_BASE_URL="https://api.conductai.ai/proxy"' in env # no trailing slash
92
+ assert 'OPENAI_BASE_URL="https://api.conductai.ai/proxy/openai"' in env
93
+
94
+
95
+ def test_unknown_shell_skips_rc_write(tmp_path, monkeypatch, capsys):
96
+ _redirect_home(tmp_path, monkeypatch)
97
+ monkeypatch.setenv("SHELL", "/bin/csh")
98
+
99
+ rc, sourced = guard._write_proxy_env("abc", "https://x")
100
+ assert rc == Path("")
101
+ assert sourced is False
102
+ # env file IS written even on unknown shell — the user can source manually
103
+ assert (tmp_path / ".conduct" / "env").exists()
104
+ assert "Source manually" in capsys.readouterr().out
File without changes
File without changes
File without changes